# Introduce a Leaderboard After Production
Make sure you have all you need before proceeding:
- You understand the concepts of Protobuf and migrations.
- Go is installed.
- You have the checkers blockchain codebase up to the wager denomination. If not, follow the previous steps or check out the relevant version (opens new window).
In this section, you will:
- Add a leaderboard.
- Upgrade your blockchain in production.
- Deal with data migrations and logic upgrades.
If you have been running v1 of your checkers blockchain for a while, games have been created, played on, won, and lost. In this section, you will introduce v2 of your blockchain with leaderboard support. A good leaderboard fulfills these conditions:
- Any player who has ever played should have a tally of games won, lost, and forfeited.
- The leaderboard should list the players with the most wins up to a pre-determined number. For example, the leaderboard could only include the top 100 scores.
- To avoid squatting and increase engagement, when equal in value the most recent score takes precedence over an older one, so the player with the recent score is listed higher on the leaderboard.
When you introduce the leaderboard, you also have to decide what to do with your existing players and their scores from your v1 checkers blockchain.
Start your v2's leaderboard as if all played past games had been counted for the leaderboard. You only need to go through all played games, update the players with their tallies, and add a leaderboard including the information. This is possible because all past games and their outcomes are kept in the chain's state. Migration is a good method to tackle the initial leaderboard.
For the avoidance of doubt, v1 and v2 refer to the overall versions of the application, and not to the consensus versions of individual modules, which may change or not.
# Introducing a leaderboard
Several things need to be addressed before you can focus all your attention on the migration:
- Save and mark as v1 the current data types about to be modified with the new version. Data types that will remain unmodified need not be identified as such.
- Prepare your v2 blockchain:
- Define your new data types.
- Add helper functions to encapsulate clearly defined actions, like leaderboard sorting.
- Adjust the existing code to make use of and update the new data types.
- Prepare for your v1-to-v2 migration:
- Add helper functions to process large amounts of data from the latest chain state of type v1.
- Add a function to migrate your state from v1 to v2.
- Make sure you can handle large amounts of data.
Why do you need to make sure you can handle large amounts of data? The full state at the point of migration may well have millions of games. You do not want your process to grind to a halt because of a lack of memory or I/O capacity.
# Save your v1
Your migration steps will be handled in a new folder, x/checkers/migrations/v1tov2
, which needs to be created:
The only data structure you will eventually change is the checkers genesis structure. The other data structures are new, so you can treat them as usual. Although in this specific case it is not strictly necessary, it is good to make this practice habitual. Copy and paste your compiled Protobuf v1 genesis from the current commit and save it under the same name in x/checkers/migrations/v1/types/
:
Your current genesis definition eventually becomes your v2 genesis. This should be the only data structure requiring a change. However, if for example you also changed the structure of StoredGame
, then you would have to save its v1 version in the same v1/types
folder.
# New v2 information
It is time to take a closer look at the new data structures being introduced with the version upgrade.
If you feel unsure about creating new data structures with Ignite CLI, look at the previous sections of the exercise again.
To give the new v2 information a data structure, you need the following:
Add a set of stats per player: it makes sense to save one
struct
for each player and to map it by address. Remember that a game is stored at a notionalStoredGame/value/123/
(opens new window), whereStoredGame/value/
(opens new window) is a constant prefix. Similarly, Ignite CLI creates a new constant to use as the prefix for players:The new
PlayerInfo/value/
prefix for players helps differentiate between the value for players and the value for games prefixed withStoredGame/value/
.
Now you can safely have bothStoredGame/value/123/
andPlayerInfo/value/123/
side by side in storage.This creates a Protobuf file:
It also added the map of new objects to the genesis, effectively your v2 genesis:
You will use the player's address as a key to the map.
Adjust in
types/genesis_test.go
for the expectation that you get an empty list to start with:Add a leaderboard rung structure to be repeated inside the leaderboard: this stores the information of a player scoring high enough to be included in the leaderboard. It is not meant to be kept directly in storage as it is only a part of the leaderboard. Instead of involving Ignite CLI, create the structure by hand in a new file:
playerAddress
indicates the player, and gives information regardingPlayerInfo.index
.wonCount
determines the ranking on the leaderboard - the higher the count, the closer to the0
index in the array. This should exactly match the value found in the corresponding player stats. This duplication of data is a lesser evil because ifwonCount
was missing you would have to access the player stats to sort the leaderboard.dateAdded
indicates when the player'swonCount
was last updated and determines the ranking when there is a tie inwonCount
- the more recent, the closer to the0
index in the array.
Add a structure for the leaderboard: there is a single stored leaderboard for the whole application. Let Ignite CLI help you implement a structure:
This creates a Protobuf file with
string winners
. You update it with your preferred type and itsimport
. Add that each element in the map is not nullable. This will compile eachWinningPlayer
to a Go object instead of a pointer:The v2 genesis was also updated with the leaderboard. Tell it that the leaderboard should always be there (even if empty):
At this point, you should run
ignite generate proto-go
so that the corresponding Go objects are re-created.Remember to make sure the initial value stored for the leaderboard is not
nil
but instead is empty. Ingenesis.go
adjust:This function returns a default genesis. This step is important if you start fresh. In your case, you do not begin with an "empty" genesis but with one resulting from the upcoming genesis migration in this exercise.
In particular, update the initial genesis test:
Also adjust the compilation errors:
On
genesis.go
:On
genesis_test.go
:At this point you can add a test case that catches a duplicated winner player:
This latest test case will pass, unless you update the
Validate()
method of the genesis to not allow duplicate player addresses. This is inspired bytypes/genesis.go
and best kept in a separatetypes/leaderboard.go
:After this, you can adjust the
types/genesis.go
files:
With the structure set up, it is time to add the code using these new elements in normal operations.
# v2 player information helpers
When a game reaches its resolution, one of the count
s needs to add +1
.
To start, add a private helper function that gets the stats from the storage, updates the numbers as instructed, and saves it back:
You can easily call this from these public one-liner functions added to the keeper:
Which player should get +1
, and on what count? You need to identify the loser and the winner of a game to determine this. Create another private helper:
You can call this from these public helper functions added to the keeper:
Be aware of the two new error types: ErrThereIsNoWinner
(opens new window) and ErrWinnerNotParseable
(opens new window).
# v2 player information handling
Now call your helper functions:
On a win:
On a forfeit:
# v2 leaderboard helpers
Continue completing your v2 before tackling the migration. Your leaderboard helpers should:
- Add a new candidate to your array.
- Sort the array according to the rules.
- Clip the array to a length of 100 and save the result.
Sorting entails comparing dates in cases of a score tie. This is potentially expensive if you are deserializing the date in the comparator itself. Instead, the comparator should be presented with data already deserialized. Prepare a data structure that has already deserialized the dateAdded
, which allows you to:
- Deserialize all the elements of the whole leaderboard's array.
- Sort its elements.
- Only then re-serialize its elements.
Create a new file leaderboard.go
to encapsulate all your leaderboard helpers:
You can reuse the date format used for the deadline:
Add similar functions to it, as you did when adding a deadline:
Do the same for the new error message:
Create the methods:
The functions are called repeatedly when serializing or deserializing arrays:
As you have a function to get an array of deserialized winning players, you can now add a function to sort the slice in place:
It tests in descending order, first for scores and then for the added dates.
There is no de-serialization in this func(i, j int) bool
callback. It is possible to write a one-liner inside this function but at the expense of readability.
Now you will make sure that your leaderboard never exceeds a certain length. Define the maximum length:
You now have the pieces in place to create the function that adds or updates a candidate to the leaderboard:
# v2 leaderboard handling
You have created the leaderboard helper functions. In a separate file, add one last function to the keeper to implement the leaderboard. This function makes it possible to add a candidate winner and save the updated leaderboard:
Be aware of the new error ErrCannotAddToLeaderboard
(opens new window).
This completes most of the leaderboard preparation. The only task left is to call your new functions at the right junctures:
On a win:
On a forfeit:
Your leaderboard will now be updated and saved on an on-going basis as part of your v2 blockchain.
# Unit tests
With all these changes, it is worthwhile adding tests.
# Player info handling unit tests
Confirm with new tests that the player's information is created or updated on a win, a loss, and a forfeit. For instance, after a winning move:
You can add similar tests that confirm that nothing happens after a game creation (opens new window), a reject (opens new window), or a non-winning move (opens new window). You should also check that a forfeit is registered (opens new window).
# Leaderboard handling unit tests
Start by adding tests that confirm that the sorting of the leaderboard's winners works as expected. Here an array of test cases is a good choice:
With that done, you can confirm that the updating or addition of new player info to the leaderboard works as expected, again with an array of test cases:
Do not forget to also test at the length limit. For instance:
With the tests at the leaderboard level done, you can move to unit tests at the keeper level. Confirm that there are no changes on creating a game (opens new window), rejecting one (opens new window), and on a regular move (opens new window).
Confirm that a new winner is either added (opens new window) or updated in the leaderboard:
Now do the same on a forfeit (opens new window).
This completes your Checkers V2 chain. If you were to start it anew as is, it would work. However, you already have the V1 of Checkers running, so you need to migrate everything.
# v1 to v2 player information migration helper
With your v2 blockchain now fully operational on its own, it is time to work on the issue of stored data migration.
First, tackle the creation of player information. You will build the player information by extracting it from all the existing stored games. In the map/reduce (opens new window) parlance, you will reduce this information from the stored games.
# Problem description
If performance was not an issue, an easy way to do it would be the following:
- Call
keeper.GetAllStoredGame()
(opens new window) to get an array with all the games. - Keep only the games that have a winner.
- Then for each game:
- Call
keeper.GetPlayerInfo
or, if that is not found, create player info, both for the black player and the red player. - Do
+1
on.WonCount
or.LostCount
according to thegame.Winner
field. - Call
keeper.SetPlayerInfo
for both black and red players.
- Call
Of course, given inevitable resource limitations, you would run into the following problems:
- Getting all the games in a single array may not be possible, because your node's RAM may not be able to keep a million of them in memory. Or maybe it fails at 100,000 of them.
- Calling
.GetPlayerInfo
and.SetPlayerInfo
twice per game just to do+1
adds up quickly. Remember that both of these calls are database calls. You could be confronted with a 12-hour job, during which your chain is offline. - Doing it all in a sequential manner would take even more time, as each blocking call blocks the whole process.
# Proposed solution
Fortunately, there exist ways to mitigate these limitations:
- You do not need to get all the games at once. The
keeper.StoredGameAll
(opens new window) function offers pagination. With this, you can limit the impact on the RAM requirement, at the expense of multiple queries. - Within each subset of games, you can compute in memory the player list and how many wins and losses each player has. With this mapping done, you can add the (in-memory) intermediary
WonCount
andLostCount
sums to each player's stored sums. With this, a+1
is potentially replaced by a+k
, at once reducing the number of calls to.GetPlayerInfo
and.SetPlayerInfo
. - You can separate the different calls and computations into Go routines (opens new window) so that a blocking call does not prevent other computations from taking place in the meantime.
The routines will use channels to communicate between themselves and the main function:
- A stored-game channel, that will pass along chunks of games in the
[]types.StoredGame
format. - A player-info channel, that will pass along intermediate computations of player information in the simple
types.PlayerInfo
format. - A done channel, whose only purpose is to flag to the main function when all has been processed.
The processing routines will be divided as per the following:
The game processing routine will:
- Receive separate arrays of games from the stored-game channel.
- Compute the aggregate player info records from them. I.e. map.
- Send the results on the player-info channel.
Also, if it detects that no more games are coming, it will close the player-info channel.
The player info processing routine will:
- Receive individual player info records from the player-info channel.
- Fetch the existing (or not) corresponding player info from the store.
- Update the won and lost counts, i.e. reduce. Remember, here it is doing
+= k
, not+= 1
. - Save it back to the store.
Also, if it detects that no more player info records are coming, it will flag it on the done channel.
The main function will:
- Launch the above 2 routines.
- Fetch all games in paginated arrays.
- Send the separate arrays on the stored-game channel.
- Close the stored-game channel after the last array.
- Wait for the flag on the done channel.
- Exit.
# Implementation
The player info processing will handle an in-memory map of player addresses to their information: map[string]*types.PlayerInfo
. Create a new file to encapsulate this whole processing. Start by creating a helper that automatically populates it with empty values when information is missing:
Next, create the routine function to process the games:
This function can handle the edge case where black and red both refer to the same player.
Create the routine function to process the player info:
Now you can create the main function:
Not to forget a suggested chunk size when fetching stored games:
To find the ideal value, you would have to test with the real state and try different values.
# v1 to v2 leaderboard migration helper
You could decide to build the leaderboard as the player stats list is being built, mimicking the regular operation of your v2 checkers blockchain. Unfortunately, that would entail a lot of array sorting for what are just intermediate player stats. Instead, it is better to build the v2 leaderboard only after all player stats have been gathered.
In the process, there are two time-consuming parts:
- Fetching the stored player info records in a paginated way, consuming mostly database resources.
- Sorting each intermediate leaderboard, consuming mostly computation resources.
It looks beneficial to use a Go routine in this case too, and a player info channel to pass along arrays of player info records.
In practice, repeatedly building the intermediate leaderboard means adding k new winningPlayerParsed
to the sorted array, sorting it, clipping it to LeaderboardWinnerLength
, and repeating. What constitutes a good k value should be dictated by testing and performance measurements. However, you can start with your best guess in a new file created for this purpose:
# Implementation
Start by adding small helpers into a new file, so that you can easily append and sort player info records:
addParsedCandidatesAndSort
is not exported because it already assumes that the candidates
do not contain any with WinCount == 0
. This is an assumption that is not enforced.
With this, you can create the routine function that builds the leaderboard in memory and saves it to storage once at the end:
The winners are initialized at a 0
size but with a capacity of types.LeaderboardWinnerLength+chunk
, which is the expected maximum intermediate size it will reach. This initialization should ensure that the slice does not need to have its capacity increased mid-process.
Declare the main function:
# v1 to v2 migration proper
The migration proper needs to execute the previous functions in a specific order. You can encapsulate this knowledge in a function:
This does not panic in case of an error. To avoid carrying on a faulty state, the caller of this function will have to handle the panic.
You have in place the functions that will handle the store migration. Now you have to set up the chain of command for these functions to be called by the node at the right point in time.
# Consensus version and name
The upgrade
module keeps in its store the different module versions (opens new window) that are currently running. To signal an upgrade, your module needs to return a different value when queried by the upgrade
module. Change it from 2
to 3
, or whichever number works for you. First, keep both these values in their respective locations:
The consensus version number bears no resemblance to v1 or v2. The consensus version number is for the module, whereas v1 or v2 is for the whole application.
Now that you are in v2, have the module return it when asked:
You also have to pick a name for the upgrade you have prepared. This name will identify your specific upgrade when it is mentioned in a Plan
, i.e. an upgrade governance proposal. This is a name relevant at the application level:
You have to inform your app about:
- The mapping between the consensus version(s) and the migration process(es).
- The mapping between this name and the module(s) consensus versions.
Prepare these in turn.
# Callback in checkers module
Indicate that the checkers module needs to perform some upgrade steps when it is coming out of the old consensus version by calling RegisterMigration
:
Note it decides on the chunk sizes to use.
# Callback in app
The command that you are going to write needs a Configurator
. This is already created as part of your app
preparation but is not kept. Instead of recreating one, adjust your code to make it easily available. Add this field to your app
:
Now adjust the place where the configurator is created:
Create a function that encapsulates knowledge about all possible upgrades, although there is a single one here. Since it includes empty code for future use, avoid cluttering the already long NewApp
function:
Now you are ready to inform the app proper. You do this towards the end, after the call to app.SetEndBlocker
and before if loadLatest
. At the correct location:
Be aware that the monitoring
module added by Ignite causes difficulty when experimenting below with the CLI. To make it simple, you should remove all references to monitoring
(opens new window) from app.go
.
When done right, adding the callbacks is a short and easy solution.
# Interact via the CLI
You can already execute a live upgrade from the command line. The following upgrade process takes inspiration from this one (opens new window) based on Gaia. You will:
- Check out the checkers v1 code.
- Build the v1 checkers executable.
- Initialize a local blockchain and network.
- Run v1 checkers.
- Add one or more incomplete games.
- Add one or more complete games with the help of a CosmJS integration test.
- Create a governance proposal to upgrade with the right plan name at an appropriate block height.
- Make the proposal pass.
- Wait for v1 checkers to halt on its own at the upgrade height.
- Check out the checkers v2 code.
- Build the v2 checkers executable.
- Run v2 checkers.
- Confirm that you now have a correct leaderboard.
Start your engines.
# Launch v1
In a shell, checkout v1 of checkers with the content of the CosmJS client work:
Build the v1 executable for your platform:
With the release/v1/checkersd
executable ready, you can initialize the network.
Because this is an exercise, to avoid messing with your keyring you must always specify --keyring-backend test
.
Add two players:
Create a new genesis:
Give your players the same token amounts that were added by Ignite, as found in config.yml
:
To be able to run a quick test, you need to change the voting period of a proposal. This is found in the genesis:
This returns something like:
That is two days, which is too long to wait for CLI tests. Choose another value, perhaps 10 minutes, i.e. "600s"
. Update it in place in the genesis:
You can confirm that the value is in using the earlier command.
Make Alice the chain's validator too by creating a genesis transaction modeled on that done by Ignite, as found in config.yml
:
Now you can start the chain proper:
From another shell, create a few un-played games with:
The --broadcast-mode block
flag means that you can fire up many such games by just copying the command without facing any sequence errors.
To get a few complete games, you are going to run the integration tests (opens new window) against it. These tests were built to run against a running chain started by Ignite. What is different here is that:
- Blocks come slower, likely every five seconds instead of every one.
- There are no longer any faucets.
Therefore, to be able to run these tests you need to attend to the above problems, respectively:
- Adjust the timeout of each
before
andit
. Make it5*(the number of expected blocks + 1)
. For instance, if you send 2 transactions that each go in a block, adjust the timeout (opens new window) to15
:this.timeout(15_000)
. - Adjust the
"credit test accounts"
before
. Justreturn
before the firstawait askFaucet
(opens new window).
Now that you cannot call the faucet, you have to credit your test accounts with standard bank send
transactions. You can use the same values as found in the before
:
With the test accounts sufficiently credited, you can now run the integration tests. Run them three times in a row to create three complete games:
You can confirm that you have a mix of complete and incomplete games:
With enough games in the system, you can move to the software upgrade governance proposal.
# Governance proposal
For the software upgrade governance proposal, you want to make sure that it stops the chain not too far in the future but still after the voting period. With a voting period of 10 minutes, take 15 minutes. How many seconds does a block take?
This returns something like:
That many blocks_per_year
computes down to 5 seconds per block. At this rate, 15 minutes mean 180 blocks.
What is the current block height? Check:
This returns something like:
That means you will use:
What is the minimum deposit for a proposal? Check:
This returns something like:
This is the minimum amount that Alice has to deposit when submitting the proposal. This will do:
Submit your governance proposal upgrade:
This returns something with:
Where 1
is the proposal ID you reuse. Have Alice and Bob vote yes on it:
Confirm that it has collected the votes:
It should print:
See how long you have to wait for the chain to reach the end of the voting period:
In the end this prints:
Wait for this period. Afterward, with the same command you should see:
Now, wait for the chain to reach the desired block height, which should take five more minutes, as per your parameters. When it has reached that height, the shell with the running checkersd
should show something like:
At this point, run in another shell:
You should always get the same value, no matter how many times you try. That is because the chain has stopped. For instance:
Stop checkersd
with CTRL-C. It has saved a new file:
This prints:
With your node (and therefore your whole blockchain) down, you are ready to move to v2.
# Launch v2
With v1 stopped and its state saved, it is time to move to v2. Checkout v2 of checkers:
Back in the first shell, build the v2 executable:
Launch it:
It should start and display something like:
After it has started, you can confirm in another shell that you have the expected leaderboard with:
This should print something like:
Note how it took the time of the block when v1 stopped.
You can similarly confirm that the player info records are correctly saved. Congratulations, you have upgraded your blockchain almost as if in production.
Your checkers blockchain is done! It has a leaderboard, which was introduced later in production thanks to migrations.
You no doubt have many ideas about how to improve it. In particular, you could implement the missing draw mechanism, which in effect has to be accepted by both players.
To summarize, this section has explored:
- How to add a leaderboard to an existing blockchain, and the characteristics that a good leaderboard should boast.
- How to upgrade a blockchain in production, by migrating from v1 of the blockchain to v2, and the new data structures that will be introduced by the upgrade.
- How to handle the data migrations and logic upgrades implicit during migration, such as with the use of private helper functions.
- Worthwhile unit tests with regard to player info and leaderboard handling.
- A complete procedure for how to conduct the update via the CLI.