Goal: we need to verify that an upgrade can proceed without:
-
breaking clients (due to a Candid interface change)
-
discarding Motoko stable state (due to a change in stable declarations)
With Motoko, we promised to check these properties statically (before attempting the upgrade).
Let’s deliver on that promise.
The following is a simple example of how to declare a stateful counter.
Unfortunately, when we upgrade this counter (say with itself), its state is lost.
version | state | success | call |
v0 | 0 | ✓ | inc() |
v0 | 1 | ✓ | inc() |
v0 | 2 | ✓ | upgrade(v0) |
v0 | 0 | ✗ | inc() |
v0 | 1 |
In Motoko, we can declare variables to be stable (across upgrades).
Because it’s stable
, this counter’s state
is retained across upgrades.
(If not marked stable
, state
would restart from 0
on upgrade).
version | state | success | call |
v1 | 0 | ✓ | inc() |
v1 | 1 | ✓ | inc() |
v1 | 2 | ✓ | upgrade(v1) |
v1 | 2 | ✓ | inc() |
v1 | 3 |
Let’s extend the API - old clients still satisfied, new ones get extra features (the read
query).
version | state | success | call |
v1 | 3 | ✓ | inc() |
v1 | 4 | ✓ | upgrade(v2) |
v2 | 4 | ✓ | inc() |
v2 | 5 | ✓ | read() |
Observation: the counter is always positive - let’s refactor Int
to Nat
!
version | state | success | call |
v2 | 5 | ✓ | inc() |
v2 | 6 | ✓ | upgrade(v3) |
v3 | 0 | ✗ | inc() |
v3 | 1 | ✓ | read() |
BOOM: code upgraded, but counter is back to 0
.
The unthinkable has happened: state was lost in an upgrade.
The Candid interface evolved safely … but the stable types did not.
An upgrade must be able to:
-
consume any stable variable value from its predecessor, or
-
run the initializer for a new stable variable.
Since Int </: Nat
, the upgrade logic discards the saved Int
(what if it was -1
?) and re-runs the initializer instead.
What’s worse, the upgrade silently "succeeded", resetting the counter to 0
.
A stable type signature looks like the "insides" of a Motoko actor type.
For example, v2
's stable types:
An upgrade from v2
to v3
's stable types:
requires consuming an Int
as a Nat
: a type error.
An upgrade is safe provided:
-
the candid interface evolves to a subtype; and
-
the stable interface evolves to a compatible one (variable to supertype or new)
Given version v0
with candid interface v0.did
and stable type interface v0.most
:
And version v1
with candid interface v1.did
and stable type interface v1.most
,
And version v2
with candid interface v2.did
and stable type interface v2.most
,
And, finally, version v3
with candid interface v3.did
and stable type interface v3.most
:
The following table summarizes the (in)compatibilities between them:
version | candid interface | stable type interface |
v0 |
v0.did |
v0.most |
:> ✓ | <<: ✓ | |
v1 |
v1.did |
v1.most |
:> ✓ | <<: ✓ | |
v2 |
v2.did |
v2.most |
:> ✓ | <<: ✗ | |
v3 |
v3.did |
v3.most |
Motoko compiler (moc
) now supports:
-
moc --stable-types …
emits stable types to a.most
file -
moc --stable-compatible <pre> <post>
checks two.most
files for upgrade compatibility
To upgrade from cur.wasm
to nxt.wasm
we need check both Candid interface and stable variables are "compatible"
didc check nxt.did cur.did // nxt <: cur
moc --stable-compatible cur.most nxt.most // cur <<: nxt
E.g. the upgrade from v2
to v3
fails this check:
> moc --stable-compatible v2.most v3.most
(unknown location): Compatibility error [M0170], stable variable state of previous type
var Int
cannot be consumed at new type
var Nat
A common, real-world example of an incompatible upgrade can be found on the forum: https://forum.dfinity.org/t/questions-about-data-structures-and-migrations/822/12?u=claudio
In that example, a user was attempting to add a field to the record payload of an array, by upgrading from stable type interface:
type Card = {
title : Text
};
actor {
stable var map: [(Nat32, Card)]
}
to incompatible stable type interface:
type Card = {
title : Text;
description : Text
};
actor {
stable var map : [(Nat32, Card)]
}
Adding a new record field (to magic from nothing) does not work.
Motoko embeds .did
and .most
files as wasm custom sections, for use by other tools, e.g. dfx.
In future, dfx canister upgrade
will, by default:
-
query the IC for a canister’s dual interfaces,
-
check compatibility of the installed and new binary,
-
abort the upgrade when unsafe.
A side-effect of a revision to Candid (used for stabilizing variables):
-
Previously, upgrades from
v2.wasm
tov3.wasm
would fail and roll-back (no data loss). -
Candid revision meant upgrade would now "succeed", but with data loss.
("fail safe" vs "silent failure")
What if we really do want to change state
to Nat
.
Solution: introduce a new stable variable, newState
, initialized from the old one:
(Or use a variant from the start…)