mcpField
S
stable-memory

stable-memory

github.com/dfinity開発39314 回インストール今日更新
リポジトリ

stable-memory について

Internet Computer の canister を開発する Motoko・Rust 開発者向けの、安定メモリーとアップグレードのガイドです。言語ごとの永続化方法、Rust の安定構造、デプロイ前の互換性確認を説明します。

主な機能

  1. 01Motoko actor の永続化ルール
  2. 02Rust の安定構造による保存パターン
  3. 03MemoryManager による仮想メモリー分割
  4. 04アップグレードフックと移行の注意点
  5. 05アップグレード前の安定性互換チェック

活用シーン

  1. 01canister のアップグレード後もユーザーデータを保持
  2. 02Rust canister の安定ストレージ構造を選択
  3. 03複数のデータ構造向けに安定メモリーを分割
  4. 04デプロイ前に互換性のない状態変更を検出

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/dfinity/icskills --skill stable-memory

インストール後、Claude Code で/stable-memoryを入力して読み込まれたか確認します。

詳細

安定メモリとカニスターのアップグレード

これは何か

安定メモリは、カニスターのアップグレード後も保持される Internet Computer 上の永続ストレージです。通常の変数が保持されるかどうかは、言語によって異なります。

  • Rust -- ヒープデータ(thread_local! { RefCell<T> }、通常の static)は、アップグレードのたびに消去されます。必要なデータはすべて、ic-stable-structures を使うか、pre_upgrade/post_upgrade で自分でシリアライズして、安定メモリに保存する必要があります。
  • Motoko -- 拡張された直交永続化がデフォルトで有効なため、persistent actor 内の let と var は、stable キーワードやアップグレードフックがなくてもアップグレード後に保持されます。消去されるのは transient 宣言だけです。

前提条件

  • Motoko の場合:mops.toml で core = "2.0.0" を指定した mops と、moc >= 1.7.0(Map/List のドット記法に必要です)
  • Rust の場合:Cargo.toml で ic-stable-structures = "0.7" を指定

カニスター ID

外部カニスターへの依存はありません。安定メモリはローカルカニスターの機能です。

ビルドを壊すよくある間違い

  1. ユーザーデータに thread_local! { RefCell<T> } を使う(Rust) -- これはヒープメモリです。カニスターのアップグレードのたびに消去されます。この方法で保存したユーザーデータ、残高、設定は、icp deploy の後にすべて失われます。代わりに StableBTreeMap を使ってください。

  2. #[post_upgrade] ハンドラーを忘れる(Rust) -- post_upgrade 関数がない場合、アップグレード後にカニスターの状態が気づかないうちにリセットされたり、予期しない動作をしたりすることがあります。必ず #[init] と #[post_upgrade] の両方を定義してください。

  3. persistent actor で stable キーワードを使う(Motoko) -- mo:core の persistent actor では、すべての let と var の宣言が自動的に安定化されます。stable let を書くと警告 M0218 が発生し、stable var は冗長です。let と var だけを使ってください。

  4. ヒープメモリの上限と安定メモリの上限を混同する(Rust) -- ヒープ(Wasm の線形メモリ)の上限は、wasm32 では 4GB、wasm64 では 6GB です。安定メモリは数百 GB(サブネットのストレージ上限)まで拡張できます。本当の危険は、pre_upgrade/post_upgrade フックを使ってヒープデータを安定メモリにシリアライズし、そこからデシリアライズして戻す場合です。その場合、ヒープメモリのサイズとアップグレードフックの命令数上限の両方に制約されます。大規模なデータセットはアップグレード中にトラップし、カニスターが使用不能になる可能性があります。解決策は、安定メモリに直接読み書きし、ヒープを完全に経由しない安定構造体(StableBTreeMap、StableCell など)を使うことです。MemoryManager を使って安定メモリを仮想メモリに分割すれば、複数の構造体が互いの領域を上書きすることなく共存できます。

  5. 拒否されたアップグレードをデータ損失とみなす(Motoko) -- 拡張された直交永続化は、新しいプログラムを既存の状態と照合し、アップグレードの適用前に受け入れるか拒否します。アップグレードが拒否されても、データは失われません。カニスターは状態をそのまま保持して以前のバージョンで実行を続けるため、コードを修正して再デプロイしてください。

    どのスキーマ変更に移行が必要か、また移行の書き方については、migrating-motoko-actors(mops で管理される移行チェーン)が担当します。ルールと構文については、そちらを参照してください。ここで知っておくべきよくある意外な点は、既存の永続変数が保持するレコード型を変更することは互換性のない変更であり、移行が必要になるということです。フィールドをオプションで追加する場合も、レコードがどこにあるか(トップレベルの var、Map の値など)にかかわらず同様です。新しい安定フィールドの追加自体に移行が必要かどうかは、プロジェクトの設定によって異なります。拡張された移行チェーンでは、安定フィールドは型だけを宣言するため、新しいフィールドには移行が必要です。一方、通常の persistent actor では、新しいフィールドに初期化子があれば移行は不要です。まさにこのため、ルールはここで表にするのではなく、そのスキルにまとめられています。互換性のないアップグレードは、実行時に RTS error: Memory-incompatible program upgrade で拒否されます(以前の状態はそのまま保持され、データは失われません)。 デプロイ時に初めて問題に気づくことがないようにしてください。mops.toml で [canisters.<name>.check-stable] を設定すると、mops check が前バージョンの .most に対して安定性互換性チェックを実行します(mops-cli を読み込みます)。これにより、アップグレード呼び出しで RTS error が発生してからではなく、チェック時に変更に互換性があるか、移行が必要かがわかります。

  6. pre_upgrade での大きなデータのシリアライズ(Rust) -- pre_upgrade には固定の命令数上限があります。大きな HashMap を pre_upgrade で安定メモリにシリアライズすると、上限に達してトラップし、キャニスターが使用不能になります。安定メモリに直接書き込み、シリアライズ処理が不要な StableBTreeMap を使ってください。

  7. アクターの形式と --default-persistent-actors フラグの不一致(Motoko) -- 通常の actor と persistent actor はどちらも正しい形式です。どちらがコンパイルできるかは、そのコンパイラフラグによって決まります。コードが簡潔になるため、このフラグを有効にすることを推奨します。

    # mops.toml — recommended; see `mops-cli`
    [moc]
    args = ["--default-persistent-actors"]
    

    このフラグを使うと、すべてのアクターが永続化されるため、通常の actor { } と記述します。persistent キーワードを付けてもコンパイルできますが冗長で、警告 M0217 が発生します。このフラグを使わない場合は persistent actor { } が必要で、通常の actor は [M0220] this actor or actor class should be declared persistent で失敗します。アノテーションを付けても解決しません。stable、transient、pre_upgrade/post_upgrade フックはすべて失敗します。エラーはアクター自体に対するものだからです。

    // without --default-persistent-actors
    actor {
      stable var users = Map.empty<Nat, Text>();   // M0220 — `stable` does not help
    };
    
    persistent actor {
      let users = Map.empty<Nat, Text>();          // compiles; persisted automatically
    };
    

    以下の例では、どちらの設定でもコンパイルできる persistent actor を使います。

    フラグをオフにしている場合の注意点が1つあります。アクター本体にアノテーションのない let/var があると、moc はその行で [M0219] this declaration is currently implicitly transient, please declare it explicitly transient を報告し、M0220 は表示されません。この提案には従わないでください。アップグレード時に transient はデータを破棄しますし、アクターは引き続き M0220 で失敗します。解決するには、アクターに persistent を付けるか、フラグを有効にします。

実装

Motoko

mo:core 2.0 では、persistent actor により安定ストレージを簡単に利用できます。アクター本体内のすべての let と var 宣言は、アップグレードをまたいで自動的に保持されます。

import Map "mo:core/Map";
import List "mo:core/List";
import Nat "mo:core/Nat";  // required: the compiler derives Map's implicit `compare` from this module
import Text "mo:core/Text";
import Time "mo:core/Time";

persistent actor {

  // Types: in the actor body (as here) or in an imported module -- but never
  // between the imports and the actor, which is M0141. See the `writing-motoko` skill.
  type User = {
    id : Nat;
    name : Text;
    created : Int;
  };

  // These survive upgrades automatically -- no "stable" keyword needed
  let users = Map.empty<Nat, User>();
  var userCounter : Nat = 0;
  let tags = List.empty<Text>();

  // Transient data -- reset to initial value on every upgrade
  transient var requestCount : Nat = 0;

  public func addUser(name : Text) : async Nat {
    let id = userCounter;
    users.add(id, {
      id;
      name;
      created = Time.now();
    });
    userCounter += 1;
    requestCount += 1;
    id
  };

  public query func getUser(id : Nat) : async ?User {
    users.get(id)
  };

  public query func getUserCount() : async Nat {
    users.size()
  };

  // requestCount resets to 0 after every upgrade
  public query func getRequestCount() : async Nat {
    requestCount
  };
}

Motoko の永続アクターに関する主なルール:

  • Map、List、Set、Queue には let を使います。自動的に保持され、シリアライズは不要です
  • 単純な値(Nat、Text、Bool)には var を使います。自動的に保持されます
  • アップグレード時にリセットするキャッシュやカウンターには transient var を使います
  • pre_upgrade/post_upgrade は不要です。ランタイムが処理します
  • stable キーワードは不要です。冗長で、警告が発生します
mops.toml
[package]
name = "my-project"
version = "0.1.0"

[dependencies]
core = "2.0.0"

# Required. Without a [toolchain] moc pin, mops falls back to the dfx cache and
# the build dies with `dfx: not found` (see `mops-cli`).
[toolchain]
moc = "1.9.0"
Rust

Rust のキャニスターでは、永続ストレージに ic-stable-structures を使います。MemoryManager は、サブネットのストレージ容量を上限として、安定メモリ(最大で数百 GB)を仮想メモリに分割します。各仮想メモリは異なるデータ構造の格納領域になります。

Cargo.toml
[package]
name = "stable_memory_backend"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
ic-cdk = "0.19"
ic-stable-structures = "0.7"
candid = "0.10"
serde = { version = "1", features = ["derive"] }
ciborium = "0.2"
単一の安定構造(シンプルなケース)
use ic_stable_structures::{
    memory_manager::{MemoryId, MemoryManager, VirtualMemory},
    storable::{Bound, Storable},
    DefaultMemoryImpl, StableBTreeMap,
};
use ic_cdk::{init, post_upgrade, query, update};
use candid::CandidType;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::cell::RefCell;

type Memory = VirtualMemory<DefaultMemoryImpl>;

// -- Implement Storable for custom types --
// StableBTreeMap keys need Storable + Ord, values need Storable.
// Storable defines how a type is serialized to/from bytes in stable memory.
// Use CBOR (via ciborium) for serialization -- compact binary format, faster than candid.

#[derive(CandidType, Serialize, Deserialize, Clone)]
struct User {
    id: u64,
    name: String,
    created: u64,
}

impl Storable for User {
    // Recommended: prefer Unbounded to avoid backwards compatibility issues when adding new fields.
    // Bounded requires a fixed max_size -- adding a field that increases the size will break existing data.
    const BOUND: Bound = Bound::Unbounded;

    fn to_bytes(&self) -> Cow<'_, [u8]> {
        let mut buf = vec![];
        ciborium::into_writer(self, &mut buf).expect("Failed to encode User");
        Cow::Owned(buf)
    }

    fn into_bytes(self) -> Vec<u8> {
        let mut buf = vec![];
        ciborium::into_writer(&self, &mut buf).expect("Failed to encode User");
        buf
    }

    fn from_bytes(bytes: Cow<'_, [u8]>) -> Self {
        ciborium::from_reader(bytes.as_ref()).expect("Failed to decode User")
    }
}
// Bound::Bounded { max_size, is_fixed_size: true } exists for fixed-size types but is NOT
// recommended -- adding a new field later will exceed max_size and break deserialization.

// Stable storage -- survives upgrades
thread_local! {
    static MEMORY_MANAGER: RefCell<MemoryManager<DefaultMemoryImpl>> =
        RefCell::new(MemoryManager::init(DefaultMemoryImpl::default()));

    static USERS: RefCell<StableBTreeMap<u64, User, Memory>> =
        RefCell::new(StableBTreeMap::init(
            MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(0)))
        ));

    // Counter stored in stable memory via StableCell
    static COUNTER: RefCell<ic_stable_structures::StableCell<u64, Memory>> =
        RefCell::new(ic_stable_structures::StableCell::init(
            MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(1))),
            0u64,
        ));
}

#[init]
fn init() {
    // Any one-time initialization
}

#[post_upgrade]
fn post_upgrade() {
    // Stable structures auto-restore -- no deserialization needed
    // Re-init timers or other transient state here
}

#[update]
fn add_user(name: String) -> u64 {
    let id = COUNTER.with(|c| {
        let mut cell = c.borrow_mut();
        let current = *cell.get();
        cell.set(current + 1);
        current
    });

    let user = User {
        id,
        name,
        created: ic_cdk::api::time(),
    };

    USERS.with(|users| {
        users.borrow_mut().insert(id, user);
    });

    id
}

#[query]
fn get_user(id: u64) -> Option<User> {
    USERS.with(|users| users.borrow().get(&id))
}

#[query]
fn get_user_count() -> u64 {
    USERS.with(|users| users.borrow().len())
}

ic_cdk::export_candid!();
MemoryManager を使用した複数の安定構造体
use ic_stable_structures::{
    memory_manager::{MemoryId, MemoryManager, VirtualMemory},
    DefaultMemoryImpl, StableBTreeMap, StableCell, StableLog,
};
use std::cell::RefCell;

type Memory = VirtualMemory<DefaultMemoryImpl>;

// Each structure gets its own MemoryId -- NEVER reuse IDs
const USERS_MEM_ID: MemoryId = MemoryId::new(0);
const POSTS_MEM_ID: MemoryId = MemoryId::new(1);
const COUNTER_MEM_ID: MemoryId = MemoryId::new(2);
const LOG_INDEX_MEM_ID: MemoryId = MemoryId::new(3);
const LOG_DATA_MEM_ID: MemoryId = MemoryId::new(4);

thread_local! {
    static MEMORY_MANAGER: RefCell<MemoryManager<DefaultMemoryImpl>> =
        RefCell::new(MemoryManager::init(DefaultMemoryImpl::default()));

    static USERS: RefCell<StableBTreeMap<u64, Vec<u8>, Memory>> =
        RefCell::new(StableBTreeMap::init(
            MEMORY_MANAGER.with(|m| m.borrow().get(USERS_MEM_ID))
        ));

    static POSTS: RefCell<StableBTreeMap<u64, Vec<u8>, Memory>> =
        RefCell::new(StableBTreeMap::init(
            MEMORY_MANAGER.with(|m| m.borrow().get(POSTS_MEM_ID))
        ));

    static COUNTER: RefCell<StableCell<u64, Memory>> =
        RefCell::new(StableCell::init(
            MEMORY_MANAGER.with(|m| m.borrow().get(COUNTER_MEM_ID)),
            0u64,
        ));

    static AUDIT_LOG: RefCell<StableLog<Vec<u8>, Memory, Memory>> =
        RefCell::new(StableLog::init(
            MEMORY_MANAGER.with(|m| m.borrow().get(LOG_INDEX_MEM_ID)),
            MEMORY_MANAGER.with(|m| m.borrow().get(LOG_DATA_MEM_ID)),
        ));
}

Rust の安定構造体に関する重要なルール:

  • MemoryManager は安定メモリを分割します。各構造体には一意の MemoryId が割り当てられます
  • 2 つの異なる構造体で MemoryId を絶対に再利用しないでください。互いにデータを破損させます
  • StableBTreeMap のキーは Storable と Ord を実装する必要があり、値は Storable を実装する必要があります
  • カスタム型には Storable を実装します。BOUND、to_bytes、into_bytes、from_bytes を定義してください。CBOR のシリアライズには ciborium::into_writer/ciborium::from_reader を使用します(コンパクトで高速です)。フィールド追加時の後方互換性の問題を避けられるため、Bound::Unbounded を推奨します。Bound::Bounded も利用できますが、スキーマ変更後に max_size を超えるとデシリアライズに失敗するため、推奨されません
  • プリミティブ型(u64、bool、f64 など)、String、Vec<u8>、Principal はすでに Storable を実装しているため、手動で実装する必要はありません
  • 単一の値(カウンター、設定)には StableCell を使用します
  • 追記専用のログには StableLog を使用します(2 つのメモリ領域が必要です。インデックスとデータ用です)
  • thread_local! { RefCell<StableBTreeMap<...>> } が適切なパターンです。RefCell でラップするのは安定構造体であり、ヒープ上の HashMap ではありません
  • pre_upgrade/post_upgrade によるシリアライズは不要です。データはすでに安定メモリに保存されています

デプロイとテスト

Motoko:アップグレード後もデータが保持されることを確認する
# Start local replica
icp network start -d

# Deploy
icp deploy backend

# Add data
icp canister call backend addUser '("Alice")'
# Expected: (0 : nat)

icp canister call backend addUser '("Bob")'
# Expected: (1 : nat)

# Verify data exists
icp canister call backend getUserCount '()'
# Expected: (2 : nat)

icp canister call backend getUser '(0)'
# Expected: (opt record { id = 0 : nat; name = "Alice"; created = ... })

# Now upgrade the canister (simulates code change + redeploy)
icp deploy backend

# Verify data survived the upgrade
icp canister call backend getUserCount '()'
# Expected: (2 : nat) -- STILL 2, not 0

icp canister call backend getUser '(1)'
# Expected: (opt record { id = 1 : nat; name = "Bob"; created = ... })
Rust:アップグレード後もデータが保持されることを確認する
icp network start -d

icp deploy backend

icp canister call backend add_user '("Alice")'
# Expected: (0 : nat64)

icp canister call backend get_user_count '()'
# Expected: (1 : nat64)

# Upgrade
icp deploy backend

# Verify persistence
icp canister call backend get_user_count '()'
# Expected: (1 : nat64) -- data survived

icp canister call backend get_user '(0)'
# Expected: (opt record { id = 0 : nat64; name = "Alice"; created = ... })

動作を確認する

安定メモリの決定的なテストは、アップグレード後もデータが保持されることです。

# 1. Deploy and add data
icp deploy backend
icp canister call backend addUser '("TestUser")'

# 2. Record the count
icp canister call backend getUserCount '()'
# Note the number

# 3. Upgrade (redeploy)
icp deploy backend

# 4. Check count again -- must be identical
icp canister call backend getUserCount '()'
# Must match step 2

# 5. Verify transient data DID reset
icp canister call backend getRequestCount '()'
# Expected: (0 : nat) -- transient var resets on upgrade

手順 3 の後にカウントが 0 になる場合、データは安定メモリに保存されていません。ストレージの宣言を確認してください。

コメント