Update Methods
TLDR
- Annotate functions with
@update
- Read-write
- Executed on many nodes
- Consensus
- Latency ~2-5 seconds
- 20 billion Wasm instruction limit
- 4 GiB heap limit
- 96 GiB stable memory limit
- ~900 updates per second per canister
Update methods are similar to query methods, but state changes can be persisted. Here's an example of a simple update method:
from kybra import nat64, update
counter = 0
@update
def increment() -> nat64:
global counter
counter += 1
return counter
Calling increment
will increase the value of counter
by 1 and then return its current value. Because counter
is a global variable, the change will be persisted to the heap, and subsequent query and update calls will have access to the new counter
value.
Because the Internet Computer (IC) persists changes with certain fault tolerance guarantees, update calls are executed on many nodes and go through consensus. This leads to latencies of ~2-5 seconds per update call.
Due to the latency and other expenses involved with update methods, it is best to use them only when necessary. Look at the following example:
from kybra import query, update, void
message = ""
@query
def get_message() -> str:
return message
@update
def set_message(new_message: str) -> void:
global message
message = new_message
You'll notice that we use an update method, set_message
, only to perform the change to the global message
variable. We use get_message
, a query method, to read the message.
Keep in mind that the heap is limited to 4 GiB, and thus there is an upper bound to global variable storage capacity. You can imagine how a simple database like the following would eventually run out of memory with too many entries:
from kybra import Opt, query, update, void
db: dict[str, str] = {}
@query
def get(key: str) -> Opt[str]:
return db.get(key)
@update
def set(key: str, value: str) -> void:
db[key] = value
If you need more than 4 GiB of storage, consider taking advantage of the 96 GiB of stable memory. Stable structures like StableBTreeMap
give you a nice API for interacting with stable memory. These data structures will be covered in more detail later. Here's a simple example:
from kybra import Opt, query, StableBTreeMap, update, void
db = StableBTreeMap[str, str](memory_id=0, max_key_size=10, max_value_size=10)
@query
def get(key: str) -> Opt[str]:
return db.get(key)
@update
def set(key: str, value: str) -> void:
db.insert(key, value)
So far we have only seen how state changes can be persisted. State changes can also be discarded by implicit or explicit traps. A trap is an immediate stop to execution with the ability to provide a message to the execution environment.
Traps can be useful for ensuring that multiple operations are either all completed or all disregarded, or in other words atomic. Keep in mind that these guarantees do not hold once cross-canister calls are introduced, but that's a more advanced topic covered later.
Here's an example of how to trap and ensure atomic changes to your database:
from kybra import ic, Opt, query, Record, StableBTreeMap, update, Vec, void
class Entry(Record):
key: str
value: str
db = StableBTreeMap[str, str](memory_id=0, max_key_size=10, max_value_size=10)
@query
def get(key: str) -> Opt[str]:
return db.get(key)
@update
def set(key: str, value: str) -> void:
db.insert(key, value)
@update
def set_many(entries: Vec[Entry]) -> void:
for entry in entries:
if entry["key"] == "trap":
ic.trap("explicit trap")
db.insert(entry["key"], entry["value"])
In addition to ic.trap
, an explicit Python raise
or any unhandled exception will also trap.
There is a limit to how much computation can be done in a single call to an update method. The current update call limit is 20 billion Wasm instructions. If we modify our database example, we can introduce an update method that runs the risk of reaching the limit:
from kybra import nat64, Opt, query, Record, StableBTreeMap, update, void
class Entry(Record):
key: str
value: str
db = StableBTreeMap[str, str](memory_id=0, max_key_size=1_000, max_value_size=1_000)
@query
def get(key: str) -> Opt[str]:
return db.get(key)
@update
def set(key: str, value: str) -> void:
db.insert(key, value)
@update
def set_many(num_entries: nat64) -> void:
for i in range(num_entries):
db.insert(str(i), str(i))
From the dfx command line
you can call set_many
like this:
dfx canister call my_canister set_many '(100_000)'
With an argument of 100_000
, set_many
will fail with an error ...exceeded the instruction limit for single message execution
.
In terms of update scalability, an individual canister likely has an upper bound of ~900 updates per second.