Actor Model fundamentals compromised?

This is my point exactly. In the canonical Actor Model there is a place for a not-blocking call construct. However, it comes with a condition of being able to calculate the replacement behaviour which is absolutely isolated by definition.

In this case, however, the state is leaked/shared. Provided of course that I have clarity in understanding how Motoko deals with it.

C.J

I’m fluent in JS, and have some experience with Solidity and Elixir.

From JS POV, this behavior is very normal. Let’s map the logic into some concrete JS code:

class Actor {

  balance = 0

  async whenZeroBalance() {
    if (this.balance == 0) {
      await scrapeFund()
      console.log("current balance:", this.balance)
    }
  }

  // after 10sec, 1000 is magically added to balance.
  async scrapeFund() {
    await sleep(10000)
    this.balance += 1000
  }

  async topUp(amount) {
    this.balance += amount
  }
}

What happen if Alice calls whenZeroBalance() then Bob calls topUp(999)? We’ll see "current balance: 1999". This is intended, as we explicitly shared this.balance (@nerdoutcj yes, in this case it’s shared, you’re right) across multiple method calls. With this syntax we voluntarily share state.

However, we can opt to another model.

class Actor {
  _updateBalance(updater) {
    this.balance = updater(this.balance)
  }

  balance = 0
  
  async whenZeroBalance() {
    const balance = this.balance
    if (balance == 0) {
      const newBalance = await scrapeFund(balance)
      console.log("current balance:", newBalance)
      // sync state
      this._updateBalance((currBalance) => {
        if (currBalance == 0) {
          return newBalance
        } else {
          const delta = newBalance - balance
          return currBalance + delta
        }
      })
    }
  }
  
  // after 10sec, 1000 is magically added to balance.
  async scrapeFund(prevBalance) {
    await sleep(10000)
    return prevBalance + 1000
  }

  async topUp(amount) {
    this.balance += amount
  }
}

If we avoid referencing this.balance across await, and instead use the balance variable in closure, you don’t have to worry about it “surprisingly” changed after await.

What feels unnatural (surprising) to me at first sight of Motoko, is because of the mindset formed from past experience with Solidity. In Solidity each transaction is actually sync call, even across multiple contracts. Asynchrony doesn’t exit in Solidity’s model.

My thought about it is that, Motoko/Dfinity brings about a paradigm shift of computation model in blockchain, that’s a feature by design. And with power comes responsibility. We have concurrency and scaling ability, now we have to deal with state synchronization.

4 Likes

With my limited experience working with Elixir, my thought is Elixir being a FP lang without mutable data struct hides this kind of situation away. With immutable data, you’re force to pass things in closure, thus it’s simply impossible to get into race condition cus the current behavior is always referencing the latest closure. However, if a lang allow mutable data and async/await, then this situation is inherently inevitable.

(Elixir perhaps is unrelated to the topic, I’m just using it to illustrate my point since it’s the only actor model base lang I’m familiar with).

As nomeata pointed out, there are multiple layers at play here.

On one level, we have a simple form of actor (mapping to IC canisters). An actor communicates by message passing as you would expect, messages are received atomically, they can send an arbitrary number of follow-up messages without blocking on responses.

But in its raw form, this model is rather awkward to use in practice. If you have a sequence of messages and responses that you need to process in an interaction, then you’ll be forced to manually reify all the intermediate state (and control flow, including call chains) somehow and store it somewhere. This can be tedious and error-prone, and in the general case, amounts to a manual CPS-transformation of the entire program.

Async/await is best considered a layer of “syntactic sugar” on top of that, which does this transformation for you. An async function with awaits should not be thought of as the implementation of a single message, but an interaction involving a sequence of messages and responses, separated by await points.

And yes, this has the risk of luring you into a false sense of safety, where you forget that atomicity only extends to the next await. But that’s a trade-off that seems worth taking for the practical benefits.

In fact, we have been discussing ways in which the compiler or the type system could help detect erroneous state dependencies that cross await boundaries, and where the programmer would need to be explicit if they want to opt in into such a dependency. But that’s a tricky design space, and we do not have a satisfactory solution yet.

7 Likes

Yes! A perfect example.

It’s pretty much the same. The underlying execution model, as provided by the system, is the same, and the syntactic sugar in the form of async/await has the same properties.

Ah, upgrades-without-stopping … interesting topic, maybe worth it’s own thread. But here are some comments (and all of them apply to rust and motoko):

  • The system doesn’t stop you from upgrading a canister that isn’t “stopped”. But it isn’t always a good idea.

  • If you have a canister that never does outgoing calls, you can safely upgrade atomically without stopping, and have no downtime.

  • If you have a canister that you know at the moment has no outgoing calls, you can safely upgrade atomically without stopping, and have no downtime.

    Maybe your service provides lots of useful functionality without doing calls on it own, and only rarely does something that requires an outgoing call. Then you could add application logic where you instruct the canister (not the system!) to stop doing outgoing calls, wait for all outstanding to come back, and then upgrade (atomically and without stopping), without impeding the main functionality of the service.

  • If you have a conventional canister (Motoko or Rust), and you might have outstanding calls, you really really should not upgrade without stopping first.

    It’s not just that you might lose the response, but when the response comes back, the way things are set up right now, it could arbitrarily corrupt your canister state.

    The technical reason is that responses are delivered by invoking a Wasm function identified by a WebAssembly function table index, and neither Rust nor Motoko give you control over the location of functions in the table. So the new version of your canister might have a completely unrelated, internal function in that slot.

  • Theoretically, you can write canisters that you can upgrade while the call is in flight, if you make sure that the functions handling the callbacks are at the same position in the table. For example if you write your canister by hand in wasm, or beef up your Rust toolchain, or maybe some clever post-processing.

    I don’t think anyone has done or tried that so far. But the system conceptually supports this.

    In this model, you wouldn’t be using async and await, though, but you would use top-level named functions as callback handlers, and implement your service closer to the actor model, or maybe closer to a state machine. After all, you do want to handle the responses in the upgraded version, so you need to be more explicit about the flow here.

  • We have plans (but not high priority, unfortunately) to change or extend the System Interface to remove the problem that you can corrupt your state if you get this wrong, by delivering callbacks to named exported functions of the module (separate from the public methods, though).

    With that in place, you’ll be able to use write canisters that can be upgraded instantaneous and autonomously with in-flight outgoing calls at last in Rust/C/etc, and – after a bit more language design – hopefully also as an opt-in in Motoko.

8 Likes

If I have a Rust canister which has it’s own principal as the controller and no outstanding messages. Can I safely call the “upgrade canister” function on the management canister as long as you don’t do anything with the result?

Excellent question: It depends on what exactly you mean with “doing something with the result” means.

If on the raw system API level you pass an invalid table index (maxint) as the callback, you should be safe. This is the hack I suggested in my blog post for how to do one-way calls, and how recent versions of Motoko implement it. I didn’t check how to achieve this with the rust CDK, it may need to be patched.

If you (or your CDK) does pass a possible valid table index, and the next version has a unrelated function there, it wouldn’t be safe.

1 Like

If I were to to this without the CDK should I just copy the code of fn call_raw_internal cdk-rs/call.rs at main · dfinity/cdk-rs · GitHub. And replace

ic0::call_new(
            callee.as_ptr() as i32,
            callee.len() as i32,
            method.as_ptr() as i32,
            method.len() as i32,
            callback as usize as i32,
            state_ptr as i32,
            callback as usize as i32,
            state_ptr as i32,
        );

with

ic0::call_new(
            callee.as_ptr() as i32,
            callee.len() as i32,
            method.as_ptr() as i32,
            method.len() as i32,
            i32::MAX,
            state_ptr as i32,
            i32::MAX,
            state_ptr as i32,
        );

?

1 Like

Essentially yes! You wouldn’t need a state_ptr (there is no callback handler to receive that pointer), so you can pass 0 here and maybe remove some code related to state_ptr.

1 Like