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.