Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# nicp_cdk `IcStableTable` B+Tree 初期化不具合の修正設計

## 背景

`IcStableTable` を B+Tree 実装へ更新した backend canister で、最初の
`create_wallet` が `alignment must be a power of two` で reject される。

canister log により、入力検証・alias/address index 照会の後、
`ensureOwnerProfile` が `ownerProfileDb` へ最初のレコードを保存する段階で
失敗することを確認した。未使用の stable-memory 領域でも再現するため、既存
stable-memory の破損や application input は原因ではない。

## 原因

`IcStableTable` の空 B+Tree に最初の値を代入する経路は、`newNode` から
`StableAllocator.allocate` を呼ぶ。

```text
IcStableTable.[]=()
-> newNode()
-> alloc(size = nodeSize, alignment = nodeSize)
-> StableAllocator.allocate()
```

実行時には `alignment = 0` となる。従って、B+Tree instance の `nodeSize` が
初回 insert 前に `DefaultNodeSize`(1024)へ初期化・復元されていない。

## 修正方針

### 1. superblock の node size を必ず検証する

`initIcStableTable` は新規 table と既存 table のどちらでも、return 前に
`nodeSize` が 256 以上かつ 2 のべき乗であることを検証する。

- 新規 table: `nodeSize = DefaultNodeSize` を設定してから superblock を書き込む。
- 既存 SBT2 table: superblock の node size を読み、同じ条件を検証する。
- `0`、256 未満、2 のべき乗でない値: `ValueError("invalid SBT2 node size")` として
明示的に拒否する。allocator まで不正な値を渡さない。

### 2. node 確保時にも防御的に検証する

`newNode` の直前に node size の検証を置く。これは破損した header や将来の
初期化回帰が発生しても、`alignment must be a power of two` という原因不明な
エラーではなく、SBT2 layout の不正として失敗させるためである。

```nim
proc ensureValidNodeSize(nodeSize: uint32) =
if nodeSize < 256 or (nodeSize and (nodeSize - 1)) != 0:
raise newException(ValueError, "invalid SBT2 node size")
```

`initIcStableTable` の新規作成時、`readHeader` の既存読み込み時、`newNode` の
3 箇所でこの検証を利用する。

### 3. 永続フォーマットの扱い

node size が 0 の SBT2 header は正常な table として扱わない。空であることを
推測して自動 `clear` すると、破損した non-empty table を消去する危険がある。

- 既存 SBT2 header が不正: init を失敗させ、運用者に restore または明示 migration
を要求する。
- 新規 table: 正しい `DefaultNodeSize` を含む header を必ず書き込む。
- STBL v1: 現行方針どおり明示 migration を要求し、暗黙変換しない。

## 実装対象

- `src/nicp_cdk/storage/libs/stable_btree.nim`
- node size validation helper の追加
- `initIcStableTable` の新規・既存 header 経路を修正
- `newNode` 前の防御検証を追加
- `tests/storage/test_stable_btree.nim`
- 新規 table の初回 insert が成功するテスト
- reopen 後の初回/継続 insert が成功するテスト
- node size が 0 の superblock を注入した場合に `invalid SBT2 node size` で
失敗するテスト
- stable-memory canister example
- `table_set` による空 table への初回書き込みを local IC で検証する。

## 進捗

- [x] SBT2 の node size を共通ヘルパーで検証し、新規作成・既存 header 読み込み・node 確保前に適用
- [x] 新規 table の初回 insert、reopen 後の継続 insert、node size が 0 の superblock 拒否をユニットテスト化
- [x] local IC で空 table に対する `table_set` の初回書き込みを確認
- [x] 独自 object 型の value について、更新後も reopen をまたいで復元できることをテスト化

## 検証結果

- `nim c -r -d:nicpMemoryViewOnly --skipUserCfg tests/storage/test_stable_btree.nim`
- `nim c -r -d:nicpMemoryViewOnly --skipUserCfg tests/storage/test_stable_btree_api.nim`
- local IC: `table_reset` 後の `table_set("initial", "value")` が成功し、`table_get` は `("value")`、`table_len` は `(1 : nat)` を返却

## 受け入れ条件

1. 空の `IcStableTable` への最初の `[]=` が成功する。
2. `nodeSize` が常に 256 以上の 2 のべき乗である。
3. 不正な SBT2 superblock は allocator 呼び出し前に明示エラーになる。
4. backend の `create_wallet` が `ensureOwnerProfile` を通過し、wallet と各 index を
保存できる。
5. table 初期化は header と bounded cache のみを読み、全 key/value を heap に復元
しない。

## 非対象

- STBL v1 から SBT2 へのデータ migration 実装
- B+Tree node size 以外の allocator/free-list 設計変更
- application 側での空 table 自動 `clear` による回避
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# AGENTS

- `.cursor/rules/project.mdc` を必ず読み、プロジェクトルールと達成目標を確認する。
- `.cursor/rules/branch.mdc` を必ず読み、ブランチルールを確認する。
- 現在のブランチ名と一致するファイル名のファイルが、リポジトリのルートから見て `.cursor/rules/branch/` 配下にあった場合は、それを読み込んでください。
- `.agent/rules/project.mdc` を必ず読み、プロジェクトルールと達成目標を確認する。
- `.agent/rules/branch.mdc` を必ず読み、ブランチルールを確認する。
- 現在のブランチ名と一致するファイル名のファイルが、リポジトリのルートから見て `.agent/rules/branch/` 配下にあった場合は、それを読み込んでください。
- Think harder.
- Deep research.
- Internal reasoning compression. You may use internal reasoning, but do not output it. Keep internal reasoning minimal but sufficient for correctness. Do not over-explore edge cases unless explicitly asked. Return only the final answer in concise form.
Expand Down
5 changes: 3 additions & 2 deletions docs/en/stable_memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,13 @@ buffer.

### IcStableTable layout

The B+Tree persists an `SBT2` superblock, node pages, and key/value blobs.
The B+Tree persists an `SBT` superblock, node pages, and key/value blobs.
Initialization reads only the superblock and bounded cache metadata; it does
not rebuild an in-memory index by scanning all entries.

```
0..3 magic "SBT2"
0..2 magic "SBT"
3.. reserved
4.. versioned superblock, root address, count, allocator metadata
... fixed-size B+Tree node pages and variable key/value blobs
```
Expand Down
2 changes: 1 addition & 1 deletion src/nicp_cdk/storage/libs/stable_allocator.nim
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
## Persistent append allocator used by the first SBT2 layout.
## Persistent append allocator used by the SBT layout.
## Free-list reuse is intentionally kept behind this module's API so the
## on-disk header can gain bins without changing tree code.

Expand Down
42 changes: 23 additions & 19 deletions src/nicp_cdk/storage/libs/stable_btree.nim
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import ./stable_key_codec
import ../../ic_types/ic_principal

const
BTreeMagic = [byte('S'), byte('B'), byte('T'), byte('2')]
BTreeVersion* = 3'u16
BTreeMagic = [byte('S'), byte('B'), byte('T')]
BTreeVersion* = 1'u16
SuperblockSize = 256'u64
DefaultNodeSize* = 1024'u32
NodeHeaderSize = 48
Expand Down Expand Up @@ -61,6 +61,11 @@ proc get32(data: openArray[byte], at: int): uint32 =
littleEndian32(addr result, unsafeAddr data[at])
proc get64(data: openArray[byte], at: int): uint64 =
littleEndian64(addr result, unsafeAddr data[at])

proc ensureValidNodeSize(nodeSize: uint32) =
if nodeSize < 256 or (nodeSize and (nodeSize - 1)) != 0:
raise newException(ValueError, "invalid SBT node size")

proc capacity(t: IcStableTable): int = (int(t.nodeSize) - NodeHeaderSize) div SlotSize

proc encodeKey[K, V](t: IcStableTable[K, V], key: K): seq[byte] =
Expand All @@ -82,7 +87,7 @@ proc decodeKey[K, V](t: IcStableTable[K, V], data: openArray[byte]): K =

proc writeHeader[K, V](t: IcStableTable[K, V]) =
var b = newSeq[byte](int(SuperblockSize))
for i in 0 .. 3: b[i] = BTreeMagic[i]
for i in 0 .. 2: b[i] = BTreeMagic[i]
b.put32(4, uint32(BTreeVersion)); b.put32(8, t.nodeSize)
b.put32(12, t.keyCodecId); b.put32(16, t.valueCodecId)
b.put64(24, t.header.count); b.put64(32, t.header.rootAddr)
Expand All @@ -93,23 +98,22 @@ proc writeHeader[K, V](t: IcStableTable[K, V]) =
proc readHeader[K, V](t: var IcStableTable[K, V]): bool =
if t.memory.size < SuperblockSize: return false
let b = t.memory.read(0, SuperblockSize)
for i in 0 .. 3:
for i in 0 .. 2:
if b[i] != BTreeMagic[i]: return false
if b.get32(4) != uint32(BTreeVersion):
raise newException(ValueError, "unsupported SBT2 layout version")
raise newException(ValueError, "unsupported SBT layout version")
t.nodeSize = b.get32(8)
if t.nodeSize < 256 or (t.nodeSize and (t.nodeSize - 1)) != 0:
raise newException(ValueError, "invalid SBT2 node size")
ensureValidNodeSize(t.nodeSize)
let storedKeyCodecId = b.get32(12)
if storedKeyCodecId != t.keyCodecId: raise newException(ValueError, "SBT2 key codec mismatch")
if storedKeyCodecId != t.keyCodecId: raise newException(ValueError, "SBT key codec mismatch")
let storedValueCodecId = b.get32(16)
if storedValueCodecId != t.valueCodecId: raise newException(ValueError, "SBT2 value codec mismatch")
if storedValueCodecId != t.valueCodecId: raise newException(ValueError, "SBT value codec mismatch")
t.keyCodecId = storedKeyCodecId; t.valueCodecId = storedValueCodecId
t.header.count = b.get64(24); t.header.rootAddr = b.get64(32)
t.header.height = b.get32(40); t.header.firstLeaf = b.get64(48); t.header.lastLeaf = b.get64(56); t.header.blobFreeHead = b.get64(64)
t.header.arenaEnd = b.get64(72)
if t.header.arenaEnd < SuperblockSize or t.header.arenaEnd > t.memory.size:
raise newException(ValueError, "invalid SBT2 allocator metadata")
raise newException(ValueError, "invalid SBT allocator metadata")
result = true

proc readNode[K, V](t: IcStableTable[K, V], address: uint64): Node =
Expand All @@ -118,12 +122,12 @@ proc readNode[K, V](t: IcStableTable[K, V], address: uint64): Node =
let cached = t.cache.entries[slot]
if cached.valid and cached.address == address: return cached.node
if address < SuperblockSize or address > t.memory.size - uint64(t.nodeSize):
raise newException(ValueError, "SBT2 node address out of bounds")
raise newException(ValueError, "SBT node address out of bounds")
let b = t.memory.read(address, uint64(t.nodeSize))
result.kind = b[0]
if result.kind != LeafNode and result.kind != InternalNode: raise newException(ValueError, "invalid SBT2 node type")
if result.kind != LeafNode and result.kind != InternalNode: raise newException(ValueError, "invalid SBT node type")
let n = int(b.get32(4))
if n > t.capacity: raise newException(ValueError, "invalid SBT2 node slot count")
if n > t.capacity: raise newException(ValueError, "invalid SBT node slot count")
result.prev = b.get64(8); result.next = b.get64(16); result.firstChild = b.get64(24)
result.slots = newSeq[Slot](n)
for i in 0 ..< n:
Expand All @@ -134,7 +138,7 @@ proc readNode[K, V](t: IcStableTable[K, V], address: uint64): Node =
t.cache.entries[slot] = NodeCacheEntry(address: address, node: result, valid: true)

proc writeNode[K, V](t: IcStableTable[K, V], address: uint64, node: Node) =
if node.slots.len > t.capacity: raise newException(ValueError, "SBT2 node overflow")
if node.slots.len > t.capacity: raise newException(ValueError, "SBT node overflow")
var b = newSeq[byte](int(t.nodeSize)); b[0] = node.kind; b.put32(4, uint32(node.slots.len))
b.put64(8, node.prev); b.put64(16, node.next); b.put64(24, node.firstChild)
for i, s in node.slots:
Expand All @@ -150,7 +154,7 @@ proc alloc[K, V](t: var IcStableTable[K, V], size, alignment: uint64): uint64 =
result = a.allocate(t.memory, size, alignment); t.header.arenaEnd = a.arenaEnd
proc readBlobHeader[K, V](t: IcStableTable[K, V], address: uint64): (uint64, uint64) =
if address < SuperblockSize or address > t.memory.size - 16'u64:
raise newException(ValueError, "SBT2 blob header out of bounds")
raise newException(ValueError, "SBT blob header out of bounds")
let data = t.memory.read(address, 16)
(data.get64(0), data.get64(8)) # payload capacity, next free header

Expand Down Expand Up @@ -180,13 +184,13 @@ proc writeBlob[K, V](t: var IcStableTable[K, V], data: openArray[byte]): uint64

proc freeBlob[K, V](t: var IcStableTable[K, V], payloadAddress: uint64) =
if payloadAddress < SuperblockSize + 16'u64:
raise newException(ValueError, "invalid SBT2 blob address")
raise newException(ValueError, "invalid SBT blob address")
let headerAddress = payloadAddress - 16'u64
let (capacity, _) = t.readBlobHeader(headerAddress)
t.writeBlobHeader(headerAddress, capacity, t.header.blobFreeHead)
t.header.blobFreeHead = headerAddress
proc readKey[K, V](t: IcStableTable[K, V], s: Slot): seq[byte] =
if s.keyOff > t.memory.size or uint64(s.keyLen) > t.memory.size - s.keyOff: raise newException(ValueError, "SBT2 key blob out of bounds")
if s.keyOff > t.memory.size or uint64(s.keyLen) > t.memory.size - s.keyOff: raise newException(ValueError, "SBT key blob out of bounds")
t.memory.read(s.keyOff, uint64(s.keyLen))
proc bytesCompare(a, b: openArray[byte]): int =
for i in 0 ..< min(a.len, b.len):
Expand All @@ -207,6 +211,7 @@ proc childIndex[K, V](t: IcStableTable[K, V], n: Node, key: openArray[byte]): in
if bytesCompare(t.readKey(n.slots[mid]), key) <= 0: lo = mid + 1 else: hi = mid
lo
proc newNode[K, V](t: var IcStableTable[K, V], kind: uint8): uint64 =
ensureValidNodeSize(t.nodeSize)
result = t.alloc(uint64(t.nodeSize), uint64(t.nodeSize)); t.writeNode(result, Node(kind: kind))

proc initIcStableTable*[K, V](memory: StableMemoryView, codec: StableKeyCodec[K], cacheSlots: int = 16,
Expand All @@ -215,8 +220,7 @@ proc initIcStableTable*[K, V](memory: StableMemoryView, codec: StableKeyCodec[K]
if codec.id == 0 or codec.encode.isNil or codec.decode.isNil: raise newException(ValueError, "invalid StableKeyCodec")
result.memory = memory; result.nodeSize = DefaultNodeSize; result.keyCodecId = codec.id; result.valueCodecId = valueCodecId; result.codec = codec
if not result.readHeader:
if memory.size >= 4 and memory.read(0, 4) == @[byte('S'), byte('T'), byte('B'), byte('L')]:
raise newException(ValueError, "STBL v1 detected: explicit migration is required")
ensureValidNodeSize(result.nodeSize)
result.header.arenaEnd = SuperblockSize
result.writeHeader
if cacheSlots > 0:
Expand Down
53 changes: 53 additions & 0 deletions tests/storage/test_stable_btree.nim
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ type InMemoryStable = ref object
type CompositeKey = object
group: uint16
id: uint16
type UserProfile = object
id: uint64
displayName: string
active: bool
type StoredProfile = object
profile: UserProfile
labels: seq[string]
revision: uint32

proc compositeCodec(): StableKeyCodec[CompositeKey] =
StableKeyCodec[CompositeKey](id: 1001'u32,
Expand All @@ -36,6 +44,51 @@ proc memoryView(memory: InMemoryStable): StableMemoryView =
)

suite "stable B+Tree":
test "first insert succeeds for a new table and after reopen":
let backing = InMemoryStable(data: @[])
var tree = initIcStableTable[uint32, string](backing.memoryView())
tree[1'u32] = "first"
check tree.len == 1
check tree[1'u32] == "first"

var reopened = initIcStableTable[uint32, string](backing.memoryView())
reopened[2'u32] = "second"
check reopened.len == 2
check reopened[1'u32] == "first"
check reopened[2'u32] == "second"

test "custom object values persist across updates and reopen":
let backing = InMemoryStable(data: @[])
let original = StoredProfile(
profile: UserProfile(id: 42'u64, displayName: "Alice", active: true),
labels: @["owner", "verified"], revision: 1'u32
)
var tree = initIcStableTable[uint32, StoredProfile](backing.memoryView())
tree[42'u32] = original
check tree[42'u32] == original

let updated = StoredProfile(
profile: UserProfile(id: 42'u64, displayName: "Alice Smith", active: false),
labels: @["owner"], revision: 2'u32
)
tree[42'u32] = updated
check tree.len == 1

var reopened = initIcStableTable[uint32, StoredProfile](backing.memoryView())
check reopened[42'u32] == updated

test "zero node size in an SBT superblock is rejected":
let backing = InMemoryStable(data: @[])
discard initIcStableTable[uint32, string](backing.memoryView())
for index in 8 .. 11:
backing.data[index] = 0

try:
discard initIcStableTable[uint32, string](backing.memoryView())
check false
except ValueError as error:
check error.msg == "invalid SBT node size"

test "split, ordered iteration, update, and reopen":
let backing = InMemoryStable(data: @[])
var tree = initIcStableTable[uint32, string](backing.memoryView(), cacheSlots = 0)
Expand Down
Loading