Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove stale expiries in AdvanceTimeTo #3415

Merged
merged 9 commits into from
Sep 25, 2024
Merged
Show file tree
Hide file tree
Changes from 7 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
76 changes: 66 additions & 10 deletions vms/platformvm/txs/executor/state_changes.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,30 @@ func VerifyNewChainTime(

// AdvanceTimeTo applies all state changes to [parentState] resulting from
// advancing the chain time to [newChainTime].
//
// Returns true iff the validator set changed.
func AdvanceTimeTo(
backend *Backend,
parentState state.Chain,
newChainTime time.Time,
) (bool, error) {
diff, changed, err := advanceTimeTo(backend, parentState, newChainTime)
if err != nil {
return false, err
}
return changed, diff.Apply(parentState)
}

// advanceTimeTo returns the state diff on top of parentState resulting from
// advancing the chain time to newChainTime. It also returns a boolean
// indicating if the validator set changed.
//
// parentState is not modified.
func advanceTimeTo(
backend *Backend,
parentState state.Chain,
newChainTime time.Time,
) (state.Diff, bool, error) {
// We promote pending stakers to current stakers first and remove
// completed stakers from the current staker set. We assume that any
// promoted staker will not immediately be removed from the current staker
Expand All @@ -93,17 +111,21 @@ func AdvanceTimeTo(

changes, err := state.NewDiffOn(parentState)
if err != nil {
return false, err
return nil, false, err
}

// Promote any pending stakers to current if [StartTime] <= [newChainTime].
//
// Invariant: It is not safe to modify the state while iterating over it,
// so we use the parentState's iterator rather than the changes iterator.
// ParentState must not be modified before this iterator is released.
pendingStakerIterator, err := parentState.GetPendingStakerIterator()
if err != nil {
return false, err
return nil, false, err
}
defer pendingStakerIterator.Release()
Comment on lines +117 to 126
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was perilously close to being an existing race.

The iterator is released in a defer, but the changes are applied on the return line. The defer is executed after the return line is evaluated. Which previously broke the invariant (that is now documented). However, because we only ever passed in a state.Diff into this function and the state.Diff lazily allocates the btree's that are iterated over, the returned iterator wasn't a tree iterator, but just explicitly empty... Which does support writes during iteration.

The expiry tree is allocated during diff initialization, which is why this function showed a race when adding the expiry iterator.

By moving the state application to the parent caller, the defer executes before Apply and there is no chance of a race.


var changed bool
// Promote any pending stakers to current if [StartTime] <= [newChainTime].
for pendingStakerIterator.Next() {
stakerToRemove := pendingStakerIterator.Value()
if stakerToRemove.StartTime.After(newChainTime) {
Expand All @@ -116,7 +138,7 @@ func AdvanceTimeTo(

if stakerToRemove.Priority == txs.SubnetPermissionedValidatorPendingPriority {
if err := changes.PutCurrentValidator(&stakerToAdd); err != nil {
return false, err
return nil, false, err
}
changes.DeletePendingValidator(stakerToRemove)
changed = true
Expand All @@ -125,12 +147,12 @@ func AdvanceTimeTo(

supply, err := changes.GetCurrentSupply(stakerToRemove.SubnetID)
if err != nil {
return false, err
return nil, false, err
}

rewards, err := GetRewardsCalculator(backend, parentState, stakerToRemove.SubnetID)
if err != nil {
return false, err
return nil, false, err
}

potentialReward := rewards.Calculate(
Expand All @@ -147,7 +169,7 @@ func AdvanceTimeTo(
switch stakerToRemove.Priority {
case txs.PrimaryNetworkValidatorPendingPriority, txs.SubnetPermissionlessValidatorPendingPriority:
if err := changes.PutCurrentValidator(&stakerToAdd); err != nil {
return false, err
return nil, false, err
}
changes.DeletePendingValidator(stakerToRemove)

Expand All @@ -156,16 +178,20 @@ func AdvanceTimeTo(
changes.DeletePendingDelegator(stakerToRemove)

default:
return false, fmt.Errorf("expected staker priority got %d", stakerToRemove.Priority)
return nil, false, fmt.Errorf("expected staker priority got %d", stakerToRemove.Priority)
}

changed = true
}

// Remove any current stakers whose [EndTime] <= [newChainTime].
//
// Invariant: It is not safe to modify the state while iterating over it,
// so we use the parentState's iterator rather than the changes iterator.
// ParentState must not be modified before this iterator is released.
currentStakerIterator, err := parentState.GetCurrentStakerIterator()
if err != nil {
return false, err
return nil, false, err
}
defer currentStakerIterator.Release()

Expand Down Expand Up @@ -201,8 +227,38 @@ func AdvanceTimeTo(
changes.SetFeeState(feeState)
}

// Remove all expiries whose timestamp now implies they can never be
// re-issued.
//
// The expiry timestamp is the time at which it is no longer valid, so any
// expiry with a timestamp less than or equal to the new chain time can be
// removed.
//
// Ref: https://github.com/avalanche-foundation/ACPs/tree/main/ACPs/77-reinventing-subnets#registersubnetvalidatortx
//
// The expiry iterator is sorted in order of increasing timestamp.
//
// Invariant: It is not safe to modify the state while iterating over it,
// so we use the parentState's iterator rather than the changes iterator.
// ParentState must not be modified before this iterator is released.
expiryIterator, err := parentState.GetExpiryIterator()
if err != nil {
return nil, false, err
}
defer expiryIterator.Release()

newChainTimeUnix := uint64(newChainTime.Unix())
for expiryIterator.Next() {
expiry := expiryIterator.Value()
if expiry.Timestamp > newChainTimeUnix {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(No action required) Maybe add a comment indicating that expiryIterator is ordered ascending by Timestamp such that the first unexpired Expiry implies that all subsequent Expiries are also unexpired?

break
}

changes.DeleteExpiry(expiry)
}

changes.SetTimestamp(newChainTime)
return changed, changes.Apply(parentState)
return changes, changed, nil
}

func GetRewardsCalculator(
Expand Down
115 changes: 115 additions & 0 deletions vms/platformvm/txs/executor/state_changes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ import (

"github.com/stretchr/testify/require"

"github.com/ava-labs/avalanchego/ids"
"github.com/ava-labs/avalanchego/upgrade/upgradetest"
"github.com/ava-labs/avalanchego/utils/iterator"
"github.com/ava-labs/avalanchego/utils/timer/mockable"
"github.com/ava-labs/avalanchego/vms/components/gas"
"github.com/ava-labs/avalanchego/vms/platformvm/config"
"github.com/ava-labs/avalanchego/vms/platformvm/genesis/genesistest"
"github.com/ava-labs/avalanchego/vms/platformvm/state"
"github.com/ava-labs/avalanchego/vms/platformvm/state/statetest"
)
Expand Down Expand Up @@ -99,3 +102,115 @@ func TestAdvanceTimeTo_UpdatesFeeState(t *testing.T) {
})
}
}

func TestAdvanceTimeTo_RemovesStaleExpiries(t *testing.T) {
var (
currentTime = genesistest.DefaultValidatorStartTime
newTime = currentTime.Add(3 * time.Second)
newTimeUnix = uint64(newTime.Unix())

unexpiredTime = newTimeUnix + 1
expiredTime = newTimeUnix
previouslyExpiredTime = newTimeUnix - 1
validationID = ids.GenerateTestID()
)

tests := []struct {
name string
initialExpiries []state.ExpiryEntry
expectedExpiries []state.ExpiryEntry
}{
{
name: "no expiries",
},
{
name: "unexpired expiry",
initialExpiries: []state.ExpiryEntry{
{
Timestamp: unexpiredTime,
ValidationID: validationID,
},
},
expectedExpiries: []state.ExpiryEntry{
{
Timestamp: unexpiredTime,
ValidationID: validationID,
},
},
},
{
name: "unexpired expiry at new time",
initialExpiries: []state.ExpiryEntry{
{
Timestamp: expiredTime,
ValidationID: ids.GenerateTestID(),
marun marked this conversation as resolved.
Show resolved Hide resolved
},
},
},
{
name: "unexpired expiry at previous time",
initialExpiries: []state.ExpiryEntry{
{
Timestamp: previouslyExpiredTime,
ValidationID: ids.GenerateTestID(),
},
},
},
{
name: "limit expiries removed",
initialExpiries: []state.ExpiryEntry{
{
Timestamp: expiredTime,
ValidationID: ids.GenerateTestID(),
},
{
Timestamp: unexpiredTime,
ValidationID: validationID,
},
},
expectedExpiries: []state.ExpiryEntry{
{
Timestamp: unexpiredTime,
ValidationID: validationID,
},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var (
require = require.New(t)
s = statetest.New(t, statetest.Config{})
)

// Ensure the invariant that [newTime <= nextStakerChangeTime] on
// AdvanceTimeTo is maintained.
nextStakerChangeTime, err := state.GetNextStakerChangeTime(s, mockable.MaxTime)
require.NoError(err)
require.False(newTime.After(nextStakerChangeTime))

for _, expiry := range test.initialExpiries {
s.PutExpiry(expiry)
}

validatorsModified, err := AdvanceTimeTo(
&Backend{
Config: &config.Config{
UpgradeConfig: upgradetest.GetConfig(upgradetest.Latest),
},
},
s,
newTime,
)
require.NoError(err)
require.False(validatorsModified)

expiryIterator, err := s.GetExpiryIterator()
require.NoError(err)
require.Equal(
test.expectedExpiries,
iterator.ToSlice(expiryIterator),
)
})
}
}
Loading