Skip to content

Commit 6f158ba

Browse files
authored
docs: port missing best-practices content from portal (#246)
Closes #241 ## Summary - **Gap 1 (small):** Added canister-per-user architectural pattern to `concepts/app-architecture.md`, including the honest note that no successful end-to-end implementation exists yet and the correction of the scalability misconception (per-subnet is more performant than per-user) - **Gap 2 (medium):** New `guides/canister-management/trust-in-canisters.md` — cohesive user-facing trust assessment guide: two-question framework, Wasm hash verification, canister history tracking, controller verification via `icp canister status`, trust spectrum table (developer-controlled → multi-sig → SNS → black-holed), black hole canister details, and caveat for self-reinstalling canisters - **Gap 3 (medium):** Extended `guides/backends/data-persistence.mdx` with a "Storage recommendations" section: heap-vs-stable decision table, mo:core data structure guidance (Map, List, Queue, pure/Map, pure/Set), Rust cautions on Vec/String serialization, state backup strategies, transaction history patterns - **Gap 4 (small):** New `guides/canister-calls/pagination.md` — cursor-based pagination for mutable datasets, with Motoko and Rust examples, handling deleted cursors, and sort order stability notes - **Gap 5 (large):** New `guides/canister-management/troubleshooting.md` — fully rewritten for `icp` CLI; covers network latency, subnet load identification, compute allocation, boundary node info for support escalation, heap-vs-stable latency, inter-canister call optimization, frontend CSP violations, `.ic-assets.json5` security policy warnings, and Rust Wasm import errors. All dfx-era content (cycles wallet, dfxvm, WSL) excluded. - Cross-links added from `reproducible-builds.md` and `canister-control.md` to the new trust guide ## Sync recommendation `informed by dfinity/portal — docs/building-apps/best-practices/application-architectures.mdx, trust-in-canisters.mdx, storage.mdx, general.mdx (pagination item), troubleshooting.mdx`
1 parent 43c027e commit 6f158ba

9 files changed

Lines changed: 545 additions & 1 deletion

File tree

‎docs/concepts/app-architecture.md‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,19 @@ For maximum throughput, distribute canisters across multiple [subnets](network-o
7171

7272
**Trade-offs:** cross-subnet calls have higher latency and bandwidth limits. You need to design data partitioning carefully.
7373

74+
### Canister-per-user
75+
76+
Each user gets their own canister that they control. The main application canister orchestrates user canisters to implement the application's functionality. Since users control their canisters, they can install their own code, decide how to participate in the application, and determine what data to share with the main canister.
77+
78+
**When to use:** only when user sovereignty over data is a core product requirement and you accept the significant development cost.
79+
80+
**Things to know:**
81+
- The main canister must treat every user canister as potentially malicious. Any code path that interacts with user canisters must assume adversarial behavior and be hardened against it.
82+
- Development cost is very high. Handling all possible actions from potentially malicious user canisters requires expert knowledge of the ICP security and messaging model.
83+
- Spawning a user canister (via an actor class in Motoko or a management canister `create_canister` call in Rust) carries the same cost as a fresh canister install. Do it once per user at account creation, never on a hot call path.
84+
- **There is no known successful end-to-end implementation of the full canister-per-user vision.** A few projects have explored variations, but the architecture remains experimental.
85+
- Common misconception: canister-per-user is not the most scalable pattern. Canister-per-subnet is more performant because it can utilize multiple subnets without the overhead of managing a large number of small canisters.
86+
7487
## Data storage
7588

7689
Canisters store data in heap memory during execution and can persist data across upgrades using [stable memory](orthogonal-persistence.md#stable-memory): there is no external database. Libraries provide familiar data-structure abstractions on top of raw stable memory:
@@ -98,6 +111,7 @@ Start with a [single canister](#single-canister): it is the right choice for mos
98111
| Does the app have a web UI? | Add an [asset canister](#frontend-options) | Backend-only canister |
99112
| Do you need separation of concerns or hit platform limits? | [Canister-per-service](#canister-per-service) | Stay with a single canister |
100113
| Do you need to scale beyond one subnet? | [Canister-per-subnet](#canister-per-subnet) | Stay on one subnet |
114+
| Is user sovereignty over data a core requirement and are you prepared for high dev cost? | [Canister-per-user](#canister-per-user) (experimental) | None of the above |
101115

102116
Start with the simplest architecture that meets your requirements. You can always split a canister into multiple canisters later: it is much harder to merge canisters that were split prematurely.
103117

@@ -108,4 +122,4 @@ Start with the simplest architecture that meets your requirements. You can alway
108122
- [Asset canister](../guides/frontends/asset-canister.md): frontend deployment
109123
- [Canisters](canisters.md): canister internals
110124

111-
<!-- Upstream: informed by dfinity/portal docs/building-apps/best-practices/application-architectures.mdx, docs/building-apps/getting-started/app-architecture.mdx -->
125+
<!-- Upstream: informed by dfinity/portal docs/building-apps/best-practices/application-architectures.mdx, docs/building-apps/getting-started/app-architecture.mdx; canister-per-user section ported from application-architectures.mdx -->

‎docs/guides/backends/data-persistence.mdx‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,11 +571,88 @@ icp canister call backend get_user '(0 : nat64)'
571571

572572
If the count drops to 0 after upgrade, the data is not in stable memory. Review your storage declarations.
573573

574+
## Storage recommendations
575+
576+
### Choose the right storage type
577+
578+
| Memory type | Max size | Persists across upgrades | Best for |
579+
|-------------|----------|--------------------------|----------|
580+
| Heap | 4 GiB | No (Rust) / Yes (Motoko `persistent actor`) | Frequently accessed data, caches, ephemeral computation |
581+
| Stable | 500 GiB | Yes | All important data, large datasets, anything that must survive upgrades |
582+
583+
The practical rule: **use stable structures directly for any data that matters**. Avoid relying on `pre_upgrade` / `post_upgrade` hooks to serialize heap data to stable memory. Serializing large heap state during an upgrade can hit the instruction limit and trap, leaving the canister on the old code. Data in stable structures is already in stable memory from the first write — no serialization step required on upgrade.
584+
585+
For Motoko, `persistent actor` makes all `let` and `var` declarations persistent automatically. There is no need to choose manually between heap and stable memory.
586+
587+
### Language-specific recommendations
588+
589+
<Tabs syncKey="lang">
590+
<TabItem label="Motoko">
591+
592+
**Choose efficient data structures.**
593+
594+
The `mo:core` library provides stable-friendly, performant data structures. Use these in preference to the legacy `mo:base` equivalents:
595+
596+
| Use case | `mo:core` type | Replaces (`mo:base`) |
597+
|----------|----------------|----------------------|
598+
| Key-value map | `Map` | `HashMap`, `TrieMap`, `Trie`, `RBTree` |
599+
| Dynamic sequence | `List` | `Buffer` |
600+
| Double-ended queue | `Queue` | `Deque` |
601+
| Ordered map | `pure/Map` | `OrderedMap` |
602+
| Ordered set | `pure/Set` | `OrderedSet` |
603+
604+
`Map` avoids the automatic resizing overhead that `HashMap` incurs on growth. `List` handles dynamic sequences without the fragile array-copy pattern of `Buffer`.
605+
606+
**Prefer `Blob` over `[Nat8]` for binary data.**
607+
608+
`Blob` is 4× more compact than `[Nat8]` and produces significantly less GC pressure. Use `Blob` for binary assets, cryptographic values, and anywhere you would send or receive `vec nat8` in Candid. Store large `Blob`s in stable memory.
609+
610+
**Use `compacting-gc` for append-only workloads (classical persistence only).**
611+
612+
If your canister grows the heap by appending data without frequent deletions, the `--compacting-gc` flag allows the GC to handle larger heaps and reduces the cost of copying large, stationary objects. Enable it in `icp.yaml` under canister build args. Note: `--compacting-gc` applies only to the legacy classical persistence mode (`--legacy-persistence`); it is not used with the default enhanced orthogonal persistence.
613+
614+
</TabItem>
615+
<TabItem label="Rust">
616+
617+
**Exercise caution with `Vec<u8>` and `String` in state serialization.**
618+
619+
If you serialize/deserialize state that contains `Vec<u8>` or `String` values, Rust's memory layout requires copying each value during encoding and decoding. For large state, this increases the instruction cost significantly. Prefer `StableBTreeMap<Vec<u8>, ...>` (or a typed key) backed directly by stable memory over serializing heap collections on upgrade.
620+
621+
**Use `ic-stable-structures` for all persistent state.**
622+
623+
Put all important data in `StableBTreeMap`, `StableCell`, or `StableLog` from the start. This avoids the `pre_upgrade` serialization problem entirely. See [Implementing Storable for custom types](#store-data-durably) above for the correct pattern.
624+
625+
For reference on effective Rust canister patterns, see [Effective Rust Canisters](https://mmapped.blog/posts/01-effective-rust-canisters.html) and [How to audit an Internet Computer canister](https://www.joachim-breitner.de/blog/788-How_to_audit_an_Internet_Computer_canister).
626+
627+
</TabItem>
628+
</Tabs>
629+
630+
### Implement state backup mechanisms
631+
632+
Even with stable memory, consider implementing explicit backup mechanisms for state that would be catastrophic to lose. This protects against:
633+
634+
- Accidental reinstall (which wipes stable memory)
635+
- Bugs in upgrade hooks that corrupt the stable layout
636+
- Application-level errors that require rollback
637+
638+
Common approaches include exporting a snapshot of canister state to a Blob that can be stored externally, or using canister [snapshots](../canister-management/snapshots.md) to checkpoint state before an upgrade.
639+
640+
### Transaction history storage
641+
642+
If your application needs to maintain a history of transactions or events, avoid storing unbounded logs in the same canister as your main application state. Options:
643+
644+
- **Dedicated logging canister.** A separate canister that accepts append-only log entries reduces load on the main canister and keeps the history size from affecting upgrade cost.
645+
- **`StableLog` (Rust).** For canisters that can accommodate history growth, `StableLog` from `ic-stable-structures` provides an append-only log directly in stable memory.
646+
- **External history services.** Services like [CAP](https://cap.ooo/) maintain transaction provenance records that integrate with explorers and wallets, which is useful for [digital asset standards](../../references/digital-asset-standards.md) compliance.
647+
648+
Be aware that inter-canister calls to a logging service add latency and cycle cost. Size the logging approach to the transaction volume you expect.
649+
574650
## Related
575651

576652
- [Orthogonal Persistence](../../concepts/orthogonal-persistence.md): conceptual explanation of heap vs. stable memory
577653
- [Canister Lifecycle](../canister-management/lifecycle.md#what-happens-during-an-upgrade): upgrade hooks and canister lifecycle
578654
- [Stable Structures (Rust)](../../languages/rust/stable-structures.md): deep dive into `ic-stable-structures`
655+
- [Canister snapshots](../canister-management/snapshots.md): checkpoint canister state before upgrades
579656
- [Motoko](../../languages/motoko/index.md): Motoko language overview and persistence model
580657

581658
{/* Upstream: informed by dfinity/portal docs/building-apps/canister-management/storage.mdx, docs/building-apps/best-practices/storage.mdx, docs/building-apps/best-practices/idempotency.mdx */}

‎docs/guides/canister-calls/idempotency.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
---
22
title: "Safe Retries and Idempotency"
33
description: "Design idempotent canister APIs to enable safe retries for ingress calls and bounded-wait inter-canister calls, preventing double-spend and other correctness issues."
4+
sidebar:
5+
order: 5
46
---
57

68
In the case of network issues or other unexpected behavior, ICP clients (such as agents) that issue ingress update calls may be unable to determine whether their ingress request has been processed. For example, this can happen if the client loses connection until after the request's ingress expiry ends and the request's status is removed from the system state tree.

‎docs/guides/canister-calls/inter-canister-calls.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,7 @@ Calls between canisters on the same subnet complete within a single round. Cross
531531
## Next steps
532532

533533
- [Parallel inter-canister calls](parallel-inter-canister-calls.md): make multiple calls concurrently and use composite queries for efficient read patterns
534+
- [Paginating query results](pagination.md): cursor-based pagination for mutable datasets that avoids duplicates and skipped items
534535
- [Candid](candid.md): define the interface your canister exposes for inter-canister calls
535536
- [Cycles Management](../canister-management/cycles-management.md): acquire cycles, monitor balances, and set freezing thresholds
536537
- [Certified Variables](../backends/certified-variables.md): make query responses verifiable without update call overhead
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
---
2+
title: "Paginating query results"
3+
description: "How to implement reliable pagination for canister query methods, including cursor-based patterns for mutable datasets"
4+
sidebar:
5+
order: 6
6+
---
7+
8+
Many canisters expose query methods that return lists of items: messages, transactions, tokens, users. When the list grows large, returning all items in a single response is impractical. Pagination splits results into pages, but the approach matters: a naive offset-based implementation produces incorrect results as soon as the underlying dataset changes.
9+
10+
## The problem with offset-based pagination
11+
12+
The simplest pagination approach passes an `offset` (number of items to skip) and a `limit` (maximum items to return). This works correctly when the dataset is immutable, but breaks as soon as items are added or removed between pages.
13+
14+
**Example:** a user fetches page 1 (items 0-9). Before they fetch page 2, a new item is inserted at position 0. Page 2 now returns items 10-19, but item 9 has shifted to position 10 and is returned again. Item 0 from the original ordering is never seen.
15+
16+
This is a common source of bugs in applications that allow concurrent writes.
17+
18+
## Cursor-based pagination
19+
20+
Cursor-based pagination identifies the last item the caller received, rather than its position in the list. The caller passes a cursor (typically the ID or key of the last item they received), and the canister returns the next batch of items that come after that cursor.
21+
22+
Because the cursor is tied to an item identity rather than a position, insertions and deletions before the cursor position do not affect the correctness of subsequent pages.
23+
24+
### Motoko example
25+
26+
```motoko
27+
import Map "mo:core/Map";
28+
import Nat "mo:core/Nat";
29+
import Text "mo:core/Text";
30+
import Array "mo:core/Array";
31+
32+
persistent actor {
33+
34+
type Item = { id : Nat; name : Text };
35+
type Page = { items : [Item]; nextCursor : ?Nat };
36+
37+
var nextId : Nat = 0;
38+
let items = Map.empty<Nat, Item>();
39+
40+
public func insert(name : Text) : async Nat {
41+
let id = nextId;
42+
Map.add(items, Nat.compare, id, { id; name });
43+
nextId += 1;
44+
id
45+
};
46+
47+
// Returns up to `limit` items with IDs strictly greater than `afterId`.
48+
// Pass `null` for `afterId` to start from the beginning.
49+
// Returns `nextCursor = null` when there are no more items.
50+
public query func listItems(afterId : ?Nat, limit : Nat) : async Page {
51+
let threshold = switch afterId { case null 0; case (?n) n + 1 };
52+
var collected : [Item] = [];
53+
var count = 0;
54+
label scan for ((id, item) in Map.entries(items)) {
55+
if (id < threshold) continue scan;
56+
if (count >= limit) break scan;
57+
collected := Array.concat(collected, [item]);
58+
count += 1;
59+
};
60+
let nextCursor = if (count < limit) null else ?(collected[count - 1].id);
61+
{ items = collected; nextCursor }
62+
};
63+
64+
}
65+
```
66+
67+
### Rust example
68+
69+
```rust
70+
use ic_stable_structures::{StableBTreeMap, memory_manager::{MemoryId, MemoryManager, VirtualMemory}, DefaultMemoryImpl};
71+
use ic_stable_structures::storable::{Bound, Storable};
72+
use ic_cdk::{query, update};
73+
use candid::{CandidType, Deserialize};
74+
use serde::Serialize;
75+
use std::borrow::Cow;
76+
use std::cell::RefCell;
77+
78+
type Memory = VirtualMemory<DefaultMemoryImpl>;
79+
80+
#[derive(CandidType, Serialize, Deserialize, Clone)]
81+
struct Item {
82+
id: u64,
83+
name: String,
84+
}
85+
86+
impl Storable for Item {
87+
const BOUND: Bound = Bound::Unbounded;
88+
89+
fn to_bytes(&self) -> Cow<'_, [u8]> {
90+
let mut buf = vec![];
91+
ciborium::into_writer(self, &mut buf).expect("failed to encode Item");
92+
Cow::Owned(buf)
93+
}
94+
95+
fn from_bytes(bytes: Cow<'_, [u8]>) -> Self {
96+
ciborium::from_reader(bytes.as_ref()).expect("failed to decode Item")
97+
}
98+
}
99+
100+
#[derive(CandidType)]
101+
struct Page {
102+
items: Vec<Item>,
103+
next_cursor: Option<u64>,
104+
}
105+
106+
thread_local! {
107+
static MEMORY_MANAGER: RefCell<MemoryManager<DefaultMemoryImpl>> =
108+
RefCell::new(MemoryManager::init(DefaultMemoryImpl::default()));
109+
110+
static ITEMS: RefCell<StableBTreeMap<u64, Item, Memory>> =
111+
RefCell::new(StableBTreeMap::init(
112+
MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(0)))
113+
));
114+
115+
static NEXT_ID: RefCell<u64> = RefCell::new(0);
116+
}
117+
118+
#[update]
119+
fn insert(name: String) -> u64 {
120+
let id = NEXT_ID.with(|n| {
121+
let current = *n.borrow();
122+
*n.borrow_mut() = current + 1;
123+
current
124+
});
125+
ITEMS.with(|items| items.borrow_mut().insert(id, Item { id, name }));
126+
id
127+
}
128+
129+
/// Returns up to `limit` items with IDs strictly greater than `after_id`.
130+
/// Pass `None` for `after_id` to start from the beginning.
131+
/// Returns `next_cursor = None` when there are no more items.
132+
#[query]
133+
fn list_items(after_id: Option<u64>, limit: u64) -> Page {
134+
let start = after_id.map(|id| id + 1).unwrap_or(0);
135+
let mut result = Vec::new();
136+
137+
ITEMS.with(|items| {
138+
for (_, item) in items.borrow().range(start..) {
139+
if result.len() as u64 >= limit {
140+
break;
141+
}
142+
result.push(item.clone());
143+
}
144+
});
145+
146+
let next_cursor = if result.len() as u64 < limit {
147+
None
148+
} else {
149+
result.last().map(|item| item.id)
150+
};
151+
152+
Page { items: result, next_cursor }
153+
}
154+
155+
ic_cdk::export_candid!();
156+
```
157+
158+
## Handling deleted cursor items
159+
160+
When a caller resumes pagination using a cursor that no longer exists (the item was deleted), the canister should return items that come after where the cursor item would have been, based on sort order. In both examples above, the cursor is a monotonically increasing integer ID. Because deleted IDs are never reused, a query with `after_id = 42` will always return items with IDs greater than 42, even if ID 42 no longer exists.
161+
162+
If your dataset uses non-integer cursors or allows ID reuse, you need to handle this case explicitly in your query method. Return the next batch that would logically follow the cursor position in the current sort order.
163+
164+
## Sort order stability
165+
166+
Cursor-based pagination requires a consistent sort order. If the sort order can change between pages (for example, items are re-ranked by score while the user is paginating), cursor-based pagination can still produce gaps or duplicates.
167+
168+
For datasets with a stable natural sort order (by insertion time, by monotonic ID, or by an immutable attribute), cursor pagination is reliable. For datasets sorted by frequently changing attributes, consider whether pagination is the right interface at all, or whether the caller should re-fetch from the beginning when the sort order changes.
169+
170+
## Cycle cost considerations
171+
172+
Each call to a query method on a canister on the IC mainnet costs cycles. When paginating a large dataset, the total cycle cost is proportional to the number of pages fetched. If callers frequently paginate through the entire dataset, consider:
173+
174+
- Increasing the page size to reduce the number of round trips
175+
- Caching recent results at the frontend layer
176+
- Exposing a bulk export method for callers that need the full dataset
177+
178+
## Related
179+
180+
- [Inter-canister calls](./inter-canister-calls.md): calling query methods from other canisters
181+
- [Calling from clients](./calling-from-clients.md): making query calls from a browser or CLI
182+
- [Data persistence](../backends/data-persistence.md): storage patterns for canister state
183+
- [Stable structures (Rust)](../../languages/rust/stable-structures.md): deep dive into `ic-stable-structures` used in the Rust example above
184+
185+
<!-- Upstream: informed by dfinity/portal docs/building-apps/best-practices/general.mdx (pagination item) -->

‎docs/guides/canister-management/reproducible-builds.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,5 +264,6 @@ Maintaining a reproducible build over years requires more than getting it workin
264264
- [Canister lifecycle](lifecycle.md): deploy and upgrade workflow
265265
- [Canister settings](settings.md): configure controllers and make canisters immutable
266266
- [Cycles management](cycles-management.md): top up canisters before long-term deployment
267+
- [Trust in canisters](trust-in-canisters.md): how users can use reproducible build verification to assess whether a canister is safe to interact with
267268

268269
<!-- Upstream: informed by dfinity/portal — docs/building-apps/best-practices/reproducible-builds.mdx; dfinity/icp-cli-recipes — recipes/prebuilt/README.md, recipe.hbs; dfinity/icp-cli — docs/guides/using-recipes.md -->

0 commit comments

Comments
 (0)