アップグレード互換性の検証

目標:以下が発生することなくアップグレードを進められることを検証する必要があります。

  • クライアントの破壊(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 とマークされていない場合、アップグレード時に state0 に初期化されます。)

バージョン

ステート

成功

コール

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()

ステーブルインターフェースを更新する

注目:カウンタは常に正の数です。IntNat にリファクタリングしましょう!

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
};

上記は IntNat として受け取ることを要求されますが、これは 型エラー です。

2 つのインターフェースの同時更新

アップグレードが安全であることは、以下の条件が必要です。

  • Candid インターフェースがサブタイプに更新されること。

  • ステーブルインターフェースが互換性のあるもの(スーパータイプまたは新規の変数)に更新されること。

バージョン

Candid インターフェース

ステーブル型インターフェース

v0

service : {
  inc: () -> (int);
}
actor {

};

:> ✓

<<: ✓

v1

service : {
  inc: () -> (int);
}
actor {
  stable var state : Int
};

:> ✓

<<: ✓

v2

service : {
  inc: () -> (int);
  read: () -> (int) query;
}
actor {
  stable var state : Int
};

:> ✓

<<:

v3

service : {
  inc: () -> (nat);
  read: () -> (nat) query;
}
actor {
  stable var state : Nat
};

ツール

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

参考例

type Card = {
  title : Text
};
actor {
  stable var map: [(Nat32, Card)]
}

<<:

type Card = {
  title : Text;
  description : Text
};
actor {
  stable var map : [(Nat32, Card)]
}

(何もないところから魔法のように)新しいレコードフィールドを追加するのは悪いことです。

メタデータセクション

Motoko は .did.most ファイルを WASM の カスタムセクション として埋め込み、dfx などの他のツールで使用できるようにしています。

将来的には、dfx canister upgrade は、デフォルトで次のような動作を行うようになります。

  1. Canister の 2 つのインターフェースについて IC に問い合わせる。

  2. インストールされたバイナリと新しいバイナリの互換性をチェックする。

  3. 安全でない場合はアップグレードを中止する。

なぜ今になってデータロスが発生しているのか?

(変数のステーブル化のための)Candid に対する改訂の副作用です。

  • 以前は、v2.wasm から v3.wasm へのアップグレードは失敗してロールバックしていました(データ損失なし)。

  • Candid の改訂によって、アップグレードが成功する代わりにデータ損失が生じるようになっています。

("フェイルセーフ" 対 "サイレント障害")

正しい解決策

実際に stateNat に変更したい場合はどうすればよいのでしょうか。

解決策:新しいステーブル変数 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
};

(もしくは最初からバリアントを使いましょう…)