-
Notifications
You must be signed in to change notification settings - Fork 346
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
feat: support migrations during version upgrades #3112
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Kinda makes sense to me. My takeaway is this introduces a new versioned module type that is aware of the version range it supports. That seems reasonable. The logic around running migrations also seems sound so no big blocking feedback
app/app.go
Outdated
@@ -190,7 +191,7 @@ type App struct { | |||
mm *module.Manager | |||
|
|||
// module configurator |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[optional] this comment doesn't add any value so it's just visual noise
// module configurator |
|
||
type VersionedModule struct { | ||
module sdkmodule.AppModule | ||
fromVersion, toVersion uint64 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[blocking] Go docs would be helpful for these two fields. Does the range defined by [fromVersion, toVersion]
describe the versions this module supports?
module.NewVersionedModule(transfer.NewAppModule(app.TransferKeeper), v1.Version, v2.Version), | ||
module.NewVersionedModule(blob.NewAppModule(appCodec, app.BlobKeeper), v1.Version, v2.Version), | ||
module.NewVersionedModule(blobstream.NewAppModule(appCodec, app.BlobstreamKeeper), v1.Version, v2.Version), | ||
module.NewVersionedModule(upgrade.NewAppModule(app.UpgradeKeeper), v2.Version, v2.Version), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[question] why is v2.Version used as both the second and third param? Is it because the upgrade module only supports v2 and isn't enabled in v1?
See other comment but Go docs on what the fromVersion
and toVersion
describe would help.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes
app/app.go
Outdated
} | ||
} | ||
return res | ||
} | ||
|
||
func (app *App) Upgrade(ctx sdk.Context, version uint64) error { | ||
app.SetAppVersion(ctx, version) | ||
return app.mm.RunMigrations(ctx, app.configurator, version, version) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[question] why is version
used for both fromVersion
and toVersion
? If we're upgrading, from a version to a different version, I would expect these two versions to be distinct.
Can a clarifying comment be added?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changed it
// RegisterMigration implements the Configurator.RegisterMigration method | ||
func (c configurator) RegisterMigration(moduleName string, fromVersion uint64, handler module.MigrationHandler) error { | ||
if fromVersion == 0 { | ||
return sdkerrors.ErrInvalidVersion.Wrap("module migration versions should start at 1") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
does this mean no migrations can occur from v0 -> v1? Does other code already assume version starts at v1?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correct. Yes we started on v1 and there are assumptions that v0 doesn't exist and implies that the version has forgotten to be set
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thanks!
[tangent] Is there already an assertion somewhere that fails on chain start up if a chain is initialized with v0? Asking because I haven't seen an assertion like that but I expect it exists somewhere.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes I believe I've added it. I'll double check
app/module/module.go
Outdated
m.OrderBeginBlockers = moduleNames | ||
} | ||
|
||
// SetOrderEndBlockers sets the order of set end-blocker calls |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
// SetOrderEndBlockers sets the order of set end-blocker calls | |
// SetOrderEndBlockers sets the order of end-blocker calls |
OrderInitGenesis: modulesStr, | ||
OrderExportGenesis: modulesStr, | ||
OrderBeginBlockers: modulesStr, | ||
OrderEndBlockers: modulesStr, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[tangent] I don't think we need to do this at the same time as this PR but I wonder if all these could use the same order: #3052.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I was merely copying across from the SDK
Yeah it's basically that and it multiplexes based on the version it expects to be on |
WalkthroughWalkthroughThe changes introduce significant updates to the application structure and module management, with a particular focus on enhancing the upgrade process and module migrations. A new dependency for managing modules and an update to existing imports reflect a broader architectural shift towards better modularity and upgradeability. The addition of migration support during the upgrade process, as indicated by the introduction of an Changes
Assessment against linked issues
Thank you for using CodeRabbit. We offer it for free to the OSS community and would appreciate your support in helping us grow. If you find it useful, would you consider giving us a shout-out on your favorite social media? TipsChatThere are 3 ways to chat with CodeRabbit:
Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments. CodeRabbit Commands (invoked as PR comments)
Additionally, you can add CodeRabbit Configration File (
|
app/module/module.go
Outdated
// RunMigrations performs in-place store migrations for all modules. This | ||
// function MUST be called when the state machine changes appVersion | ||
func (m Manager) RunMigrations(ctx sdk.Context, cfg sdkmodule.Configurator, fromVersion, toVersion uint64) error { | ||
c, ok := cfg.(configurator) | ||
if !ok { | ||
return sdkerrors.ErrInvalidType.Wrapf("expected %T, got %T", configurator{}, cfg) | ||
} | ||
modules := m.OrderMigrations | ||
if modules == nil { | ||
modules = DefaultMigrationsOrder(m.ModuleNames(toVersion)) | ||
} | ||
currentVersionModules, exists := m.versionedModules[fromVersion] | ||
if !exists { | ||
return sdkerrors.ErrInvalidVersion.Wrapf("version %d not supported", fromVersion) | ||
} | ||
nextVersionModules, exists := m.versionedModules[toVersion] | ||
if !exists { | ||
return sdkerrors.ErrInvalidVersion.Wrapf("version %d not supported", toVersion) | ||
} | ||
|
||
for _, moduleName := range modules { | ||
_, currentModuleExists := currentVersionModules[moduleName] | ||
nextModule, nextModuleExists := nextVersionModules[moduleName] | ||
|
||
// if the module exists for both upgrades | ||
if currentModuleExists && nextModuleExists { | ||
err := c.runModuleMigrations(ctx, moduleName, fromVersion, toVersion) | ||
if err != nil { | ||
return err | ||
} | ||
} else if !currentModuleExists && nextModuleExists { | ||
ctx.Logger().Info(fmt.Sprintf("adding a new module: %s", moduleName)) | ||
moduleValUpdates := nextModule.InitGenesis(ctx, c.cdc, nextModule.DefaultGenesis(c.cdc)) | ||
// The module manager assumes only one module will update the | ||
// validator set, and it can't be a new module. | ||
if len(moduleValUpdates) > 0 { | ||
return sdkerrors.ErrLogic.Wrap("validator InitGenesis update is already set by another module") | ||
} | ||
} | ||
// TODO: handle the case where a module is no longer supported (i.e. removed from the state machine) | ||
} | ||
|
||
return nil |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The RunMigrations
method is a key component of the versioning system, allowing for in-place store migrations between versions. The method's logic, including handling new modules and ensuring that only one module can update the validator set during migration, is correctly implemented. However, the TODO comment on line 245 indicates that handling the removal of modules is not yet addressed, which is an important aspect to consider for future development.
Would you like me to open a GitHub issue to track the implementation of handling module removals during migrations?
// FIXME: this is because the ibctesting framework we use | ||
// doesn't set the version in the header which we require | ||
// to know which state machine to execute against | ||
t.Skip("token filter tests are currently not supported") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Skipping the token filter tests due to the missing version in the header is a temporary workaround. It's important to address this limitation in the testing framework to ensure that token filter functionality can be thoroughly tested, especially in the context of version-specific behavior.
Consider enhancing the testing framework to include version information in the header, allowing these tests to be re-enabled and ensuring comprehensive test coverage.
func TestManager_InitGenesis(t *testing.T) { | ||
mockCtrl := gomock.NewController(t) | ||
t.Cleanup(mockCtrl.Finish) | ||
|
||
mockAppModule1 := mocks.NewMockAppModule(mockCtrl) | ||
mockAppModule2 := mocks.NewMockAppModule(mockCtrl) | ||
mockAppModule1.EXPECT().Name().Times(2).Return("module1") | ||
mockAppModule2.EXPECT().Name().Times(2).Return("module2") | ||
mm, err := module.NewManager(module.NewVersionedModule(mockAppModule1, 1, 1), module.NewVersionedModule(mockAppModule2, 1, 1)) | ||
require.NoError(t, err) | ||
require.NotNil(t, mm) | ||
require.Equal(t, 2, len(mm.ModuleNames(1))) | ||
|
||
ctx := sdk.NewContext(nil, tmproto.Header{}, false, log.NewNopLogger()) | ||
interfaceRegistry := types.NewInterfaceRegistry() | ||
cdc := codec.NewProtoCodec(interfaceRegistry) | ||
genesisData := map[string]json.RawMessage{"module1": json.RawMessage(`{"key": "value"}`)} | ||
|
||
// this should panic since the validator set is empty even after init genesis | ||
mockAppModule1.EXPECT().InitGenesis(gomock.Eq(ctx), gomock.Eq(cdc), gomock.Eq(genesisData["module1"])).Times(1).Return(nil) | ||
require.Panics(t, func() { mm.InitGenesis(ctx, cdc, genesisData, 1) }) | ||
|
||
// test panic | ||
genesisData = map[string]json.RawMessage{ | ||
"module1": json.RawMessage(`{"key": "value"}`), | ||
"module2": json.RawMessage(`{"key": "value"}`), | ||
} | ||
mockAppModule1.EXPECT().InitGenesis(gomock.Eq(ctx), gomock.Eq(cdc), gomock.Eq(genesisData["module1"])).Times(1).Return([]abci.ValidatorUpdate{{}}) | ||
mockAppModule2.EXPECT().InitGenesis(gomock.Eq(ctx), gomock.Eq(cdc), gomock.Eq(genesisData["module2"])).Times(1).Return([]abci.ValidatorUpdate{{}}) | ||
require.Panics(t, func() { mm.InitGenesis(ctx, cdc, genesisData, 1) }) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The TestManager_InitGenesis
function tests the initialization of genesis state by modules. This includes a check for panic when the validator set is empty, which is a critical test case for ensuring the robustness of the genesis initialization process. However, the comment about expecting a panic due to an empty validator set might be misleading since the panic is actually triggered by the test setup and not by the InitGenesis
method itself.
Consider clarifying the comment regarding the expected panic in TestManager_InitGenesis
to accurately reflect the reason for the panic, ensuring that it does not mislead readers into thinking it's an inherent behavior of the InitGenesis
method.
func TestManager_EndBlock(t *testing.T) { | ||
mockCtrl := gomock.NewController(t) | ||
t.Cleanup(mockCtrl.Finish) | ||
|
||
mockAppModule1 := mocks.NewMockAppModule(mockCtrl) | ||
mockAppModule2 := mocks.NewMockAppModule(mockCtrl) | ||
mockAppModule1.EXPECT().Name().Times(2).Return("module1") | ||
mockAppModule2.EXPECT().Name().Times(2).Return("module2") | ||
mm, err := module.NewManager(module.NewVersionedModule(mockAppModule1, 1, 1), module.NewVersionedModule(mockAppModule2, 1, 1)) | ||
require.NoError(t, err) | ||
require.NotNil(t, mm) | ||
require.Equal(t, 2, len(mm.ModuleNames(1))) | ||
|
||
req := abci.RequestEndBlock{Height: 10} | ||
|
||
mockAppModule1.EXPECT().EndBlock(gomock.Any(), gomock.Eq(req)).Times(1).Return([]abci.ValidatorUpdate{{}}) | ||
mockAppModule2.EXPECT().EndBlock(gomock.Any(), gomock.Eq(req)).Times(1) | ||
ctx := sdk.NewContext(nil, tmproto.Header{ | ||
Version: tmversion.Consensus{App: 1}, | ||
}, false, log.NewNopLogger()) | ||
ret := mm.EndBlock(ctx, req) | ||
require.Equal(t, []abci.ValidatorUpdate{{}}, ret.ValidatorUpdates) | ||
|
||
// test panic | ||
mockAppModule1.EXPECT().EndBlock(gomock.Any(), gomock.Eq(req)).Times(1).Return([]abci.ValidatorUpdate{{}}) | ||
mockAppModule2.EXPECT().EndBlock(gomock.Any(), gomock.Eq(req)).Times(1).Return([]abci.ValidatorUpdate{{}}) | ||
require.Panics(t, func() { mm.EndBlock(ctx, req) }) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The TestManager_EndBlock
function tests the handling of the end block event by modules. This includes testing the aggregation of validator updates, which is crucial for the application's consensus mechanism. The test also includes a case for expecting a panic, which is important for ensuring the robustness of the end block handling. However, similar to the InitGenesis
test, the reason for the expected panic should be clarified to avoid confusion.
Clarify the comment regarding the expected panic in TestManager_EndBlock
to accurately reflect the reason for the panic, ensuring it does not mislead readers into thinking it's an inherent behavior of the EndBlock
method.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this LGTM
the mocks are great essential unit tests, and this might be planned and I just missed it, but it seems v2 blocking to have some test that will break if things outside of the Manager change. Similar to #3137, if there are other things that are necessary for the complete integration, can we make a point to document them before merging and then discuss them in the next v2 sync? cc @rootulp
fromVersion: fromVersion, | ||
toVersion: toVersion, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[not blocking question]
are there scenarios where from and to are not consecutive?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's meant as a range. The same module could be used over several app versions
Co-authored-by: Rootul P <rootulp@gmail.com>
func (app *App) Upgrade(ctx sdk.Context, version uint64) error { | ||
app.SetAppVersion(ctx, version) | ||
return app.mm.RunMigrations(ctx, app.configurator, app.AppVersion(ctx), version) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[optional] some log or event here
// genesis must always contain the consensus params. The validator set howerver is derived from the | ||
// initial genesis state |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't understand this comment, can it be rephrased? The consensus params and the validator set seem orthogonal.
Also the validator set doesn't seem applicable to the following three lines so if it's not relevant in this context, proposal to delete that portion of the comment.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I could perhaps move this comment to the function signature. I think it's important since normally there are multiple ways of setting the consensus params and validator set that we clarify which one we use
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM, added some suggestions and questions, will defer to other reviewers for the final approval.
|
||
// NewManager creates a new Manager object | ||
func NewManager(modules ...VersionedModule) (*Manager, error) { | ||
moduleMap := make(map[uint64]map[string]sdkmodule.AppModule) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could be beneficial to add a comment about the interpretation of the keys and values in this moduleMap i.e., version -> [module name -> module]
if module.fromVersion > module.toVersion { | ||
return nil, sdkerrors.ErrLogic.Wrapf("fromVersion can not be greater than toVersion for module %s", module.module.Name()) | ||
} | ||
for version := module.fromVersion; version <= module.toVersion; version++ { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mind explaining in the code what fromVersion
and toVersion
indicates for each module?
moduleMap := make(map[uint64]map[string]sdkmodule.AppModule) | ||
allModules := make([]sdkmodule.AppModule, len(modules)) | ||
modulesStr := make([]string, 0, len(modules)) | ||
firstVersion, lastVersion := uint64(0), uint64(0) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A description for what firstVersion and lastVersion hold would be helpful.
if moduleMap[version] == nil { | ||
moduleMap[version] = make(map[string]sdkmodule.AppModule) | ||
} | ||
moduleMap[version][module.module.Name()] = module.module |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[question] Why the fromVersion
is taken into account but not the toVersion
(I guess it is related to my earlier question about the usage of fromVersion and toVersion).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't quite understand this question. All version fromVersion
to toVersion
are added to the moduleMap
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What I meant to say is that the version
variable contains the value obtained from module.fromModule
. In the map, however, we only store the module corresponding to fromVersion
and not toVersion
. This leads to my confusion: shouldn't the same process apply to all versions ranging from fromVersion
to toVersion
? This mostly relates to my earlier question regarding the purpose of toVersion
.
Closes: celestiaorg#3105 --------- Co-authored-by: Rootul P <rootulp@gmail.com>
Closes: #3105