アップグレード互換性の検証
目標:以下が発生することなくアップグレードを進められることを検証する必要があります。
-
クライアントの破壊(Candid インターフェースの変更に起因)
-
Motoko のステーブルステートを破棄する(ステーブル宣言の変更に起因)
Motokoでは、これらの性質を(アップグレードを試みる前に)静的にチェックすることを約束しています。
その約束が果たされる様子を見ていきましょう。
ステーブルでないカウンタ
以下は、ステートフルなカウンタを宣言する簡単な例です。
actor Counter_v0 {
var state : Int = 0;
public func inc() : async Int {
state += 1;
return state;
};
}
残念ながら、このカウンタを(例えば同じコードで)アップグレードすると、ステートが失われてしまいます。
バージョン |
ステート |
成功 |
コール |
v0 |
0 |
✓ |
inc() |
v0 |
1 |
✓ |
inc() |
v0 |
2 |
✓ |
upgrade(v0) |
v0 |
0 |
✗ |
inc() |
v0 |
1 |
ステーブルなカウンタ
Motoko では、変数を(アップグレードをまたいで)stable と宣言することができます。
actor Counter_v1 {
stable var state : Int = 0;
public func inc() : async Int {
state += 1;
return state;
};
}
stable
なので、このカウンタのステート(state
)はアップグレードしても 保持 されます。
(stable
とマークされていない場合、アップグレード時に state
は 0
に初期化されます。)
バージョン |
ステート |
成功 |
コール |
v1 |
0 |
✓ |
inc() |
v1 |
1 |
✓ |
inc() |
v1 |
2 |
✓ |
upgrade(v1) |
v1 |
2 |
✓ |
inc() |
v1 |
3 |
Candid インターフェースを更新する
API を拡張しましょう。
古いクライアントは引き続き動作するようにし、新しいクライアントは追加の機能(read
クエリ)を獲得します。
actor Counter_v2 {
stable var state : Int = 0;
public func inc() : async Int {
state += 1;
return state;
};
public query func read() : async Int { return state; }
}
バージョン |
ステート |
成功 |
コール |
v1 |
3 |
✓ |
inc() |
v1 |
4 |
✓ |
upgrade(v2) |
v2 |
4 |
✓ |
inc() |
v2 |
5 |
✓ |
read() |
ステーブルインターフェースを更新する
注目:カウンタは常に正の数です。Int
を Nat
にリファクタリングしましょう!
actor Counter_v3 {
stable var state : Nat = 0;
public func inc() : async Nat {
state += 1;
return state;
};
public query func read() : async Nat { return state; }
}
バージョン |
ステート |
成功 |
コール |
v2 |
5 |
✓ |
inc() |
v2 |
6 |
✓ |
upgrade(v3) |
v3 |
0 |
✗ |
inc() |
v3 |
1 |
✓ |
read() |
ドーン:コードはアップグレードされましたが、カウンタが 0
に戻ってしまいました。
考えられない事態が発生しました。アップグレードでステートが失われたのです。
何が起こった?
Candid インターフェースは安全に更新されましたが… ステーブル型は安全に更新されませんでした。
アップグレードは、以下を必ず実行しなければなりません。
-
ステーブル変数の値をアップグレード前のものから受け取る。
-
新しいステーブル変数の初期化を実行する。
Int </: Nat
なので、アップグレードのロジックは保存されていた Int
を破棄し(例えばそれが -1
だったらどうでしょう?)、代わりに初期化ロジックを再実行します。
さらに悪いことに、アップグレードは静かに成功し、カウンタを 0
にリセットします。
ステーブル型のシグネチャ
ステーブル型のシグネチャは、Motoko の Actor 型の中身のようなものです。
例えば、v2
のステーブル型は以下の通りです。
actor {
stable var state : Int
};
v2
からアップグレードされた v3
のステーブル型は以下の通りです。
actor {
stable var state : Nat
};
上記は Int
を Nat
として受け取ることを要求されますが、これは 型エラー です。
2 つのインターフェースの同時更新
アップグレードが安全であることは、以下の条件が必要です。
-
Candid インターフェースがサブタイプに更新されること。
-
ステーブルインターフェースが互換性のあるもの(スーパータイプまたは新規の変数)に更新されること。
バージョン |
Candid インターフェース |
ステーブル型インターフェース |
v0 |
|
|
:> ✓ |
<<: ✓ |
|
v1 |
|
|
:> ✓ |
<<: ✓ |
|
v2 |
|
|
:> ✓ |
<<: ✗ |
|
v3 |
|
|
ツール
Motoko コンパイラ (moc
) は以下をサポートするようになりました。
-
moc --stable-types …
は、ステーブル型を.most
ファイルに出力します。 -
moc --stable-compatible <pre> <post>
は、2 つの.most
ファイルをチェックし、アップグレードの互換性を確認します。
cur.wasm
から nxt.wasm
にアップグレードするには、Candid インターフェースと ステーブル変数の 両方 に互換性があることを確認する必要があります。
didc check nxt.did cur.did // nxt <: cur
moc --stable-compatible cur.most nxt.most // cur <<: nxt
例えば、v2
から v3
へのアップグレードは、チェックを行うと失敗します。
> 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
参考例
|
<<: ✗ |
|
(何もないところから魔法のように)新しいレコードフィールドを追加するのは悪いことです。
メタデータセクション
Motoko は .did
と .most
ファイルを WASM の カスタムセクション として埋め込み、dfx などの他のツールで使用できるようにしています。
将来的には、dfx canister upgrade
は、デフォルトで次のような動作を行うようになります。
-
Canister の 2 つのインターフェースについて IC に問い合わせる。
-
インストールされたバイナリと新しいバイナリの互換性をチェックする。
-
安全でない場合はアップグレードを中止する。
なぜ今になってデータロスが発生しているのか?
(変数のステーブル化のための)Candid に対する改訂の副作用です。
-
以前は、
v2.wasm
からv3.wasm
へのアップグレードは失敗してロールバックしていました(データ損失なし)。 -
Candid の改訂によって、アップグレードが成功する代わりにデータ損失が生じるようになっています。
("フェイルセーフ" 対 "サイレント障害")
正しい解決策
実際に state
を Nat
に変更したい場合はどうすればよいのでしょうか。
解決策:新しいステーブル変数 newState
を導入し、古い変数を用いて初期化します。
import Int "mo:base/Int";
actor Counter_v4 {
stable var state : Int = 0;
stable var newState : Nat = Int.abs(state);
public func inc() : async Nat {
newState += 1;
return newState;
};
public query func read() : async Nat { return newState; }
}
actor {
stable var newState : Nat;
stable var state : Int
};
(もしくは最初からバリアントを使いましょう…)