Skip to content

Persist the state with vm.invoke #4175

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
lastmjs opened this issue Sep 21, 2022 · 5 comments
Open

Persist the state with vm.invoke #4175

lastmjs opened this issue Sep 21, 2022 · 5 comments

Comments

@lastmjs
Copy link

lastmjs commented Sep 21, 2022

Is there a way to persist the state/scope when using vm.invoke? For my application, I have a global interpreter that is executed once with the user's code. This sets up their Python application. This is running on the Internet Computer, basically a decentralized cloud environment. The interpreter will always be running, such as in a long-running server process.

I need the interpreter and the scope to be persisted across calls. Right now record_message does not persist the message. I'm not sure how to provide the scope appropriately when using vm.invoke.

saved_message = ''

@update
def record_message(message: str) -> str:
    saved_message = message
    return saved_message

@query
def get_message() -> str:
    return saved_message
use rustpython;
use rustpython::vm::convert::ToPyObject;
static mut _KYBRA_INTERPRETER_OPTION: Option<rustpython::vm::Interpreter> = None;
static mut _KYBRA_SCOPE_OPTION: Option<rustpython::vm::scope::Scope> = None;

// There's other code, such as initializing the interpreter and the scope, that is not shown here

#[ic_cdk_macros::update]
#[candid::candid_method(update)]
async fn record_message(message: String) -> String {
    unsafe {
        let _kybra_interpreter = _KYBRA_INTERPRETER_OPTION.as_mut().unwrap();
        let _kybra_scope = _KYBRA_SCOPE_OPTION.as_mut().unwrap();
        let result = _kybra_interpreter.enter(|vm| {
            let method_py_object_ref = _kybra_scope.globals.get_item("record_message", vm).unwrap();
            let result_py_object_ref = vm
                .invoke(
                    &method_py_object_ref,
                    (message.try_into_vm_value(vm).unwrap(),),
                )
                .unwrap();

            result_py_object_ref.try_from_vm_value(vm).unwrap()
        });
        result
    }
}

#[ic_cdk_macros::query]
#[candid::candid_method(query)]
async fn get_message() -> String {
    unsafe {
        let _kybra_interpreter = _KYBRA_INTERPRETER_OPTION.as_mut().unwrap();
        let _kybra_scope = _KYBRA_SCOPE_OPTION.as_mut().unwrap();
        let result = _kybra_interpreter.enter(|vm| {
            let method_py_object_ref = _kybra_scope.globals.get_item("get_message", vm).unwrap();
            let result_py_object_ref = vm.invoke(&method_py_object_ref, ()).unwrap();

            result_py_object_ref.try_from_vm_value(vm).unwrap()
        });
        result
    }
}
@lastmjs lastmjs mentioned this issue Sep 21, 2022
41 tasks
@youknowone
Copy link
Member

First of all, this is not related to your question directly, but how did you initialize the scope? If you have a python module like the upper one, it will be initialized like:

let module = vm.import(<path>, None, 0);

and then

let record_message = module.get_attr("record_message", vm);
...

Then vm.invoke is a high-level interface corresponds to calling __call__ in Python. As same as python, we cannot inject scope during __call__ invocation. I'd like to recommend to pass data as arguments in this case.

If you are going to access a low-level interface, you can create code object instead of a module.

let code_obj = vm.compile(<path>); // or compile_string with source string

Then you can use vm.run_code_obj(code_obj, scope). The scope can hold global variables now.

@lastmjs
Copy link
Author

lastmjs commented Sep 21, 2022

I will post code later, but to initialize the interpreter I just use run_code_string with all of the user's code.

Hmm, this is troubling for my use case. I need to set up the user's code once when the canister is initialized. A canister is like an everlasting server process, it is a smart contract on the Internet Computer that has its own dedicated Wasm memory.

Users interact with a canister by performing query and update calls over http. An update call is basically a function call that persists state.

I need to be able to call a Python function during an update call, pass in the arguments that the user has provided, get a result, and have whatever state changes occured during the Python function call persisted. I'm not seeing how I can practically achieve this by passing arguments into invoke, nor by using run_code_obj.

For Azle, our TypeScript/JavaScript environment for the IC, the JS engine we use Boa simply automatically persists its state. Once you create a VM instance (known as a context), all of the state is in there. If you grab a function and call it, all state changes will remain in the memory of the VM.

Can I not achieve this with RustPython? This is a MAJOR blocker at this point. Hopefully I'm missing something.

@lastmjs
Copy link
Author

lastmjs commented Sep 21, 2022

Here's a more complete program after all of our compilation (I've left out a couple traits and impls to save on verbosity). This will run on the Internet Computer inside of a canister. The comments explain the situation:

// This file represents an entire Rust canister. It will be compiled to wasm32-unknown-unknown and deployed to an Internet Computer replica.
// On first deploy, _kybra_init is executed. This initializes the developer's code
// On subsequent deploys, _kybra_post_upgrade is executed
// Between deploys, the canister is an everlasting process. Its memory is automatically persisted by the system
// Users interact with the canister by calling into the query and update methods
// query methods do not persist state
// update methods MUST persist state

use rustpython;
use rustpython::vm::convert::ToPyObject;

// We store the interpreter and scope here so that we can access them during each query or update call
// Ideally we could just store the interpreter and it would simply maintain state, this is how the JS engine Boa works for Azle
static mut _KYBRA_INTERPRETER_OPTION: Option<rustpython::vm::Interpreter> = None;
static mut _KYBRA_SCOPE_OPTION: Option<rustpython::vm::scope::Scope> = None;

// MAIN_PY has the user's code. We take it from the filesystem during the Kybra compilation process
static MAIN_PY : & 'static str = "# TODO should go in an azle.py file that can be imported\n# from typing import Any # TODO importing Any seems to break in many cases (maybe all cases?)\n\nint64 = int\nint32 = int\nint16 = int\nint8 = int\n\nnat = int\nnat64 = int\nnat32 = int\nnat16 = int\nnat8 = int\n\nfloat32 = float\nfloat64 = float\n\ntext = str\n\n# blob = bytes\n\n# reserved = Any\n# empty = Any\n\ndef query(func: object):\n    return func\n\ndef update(func: object):\n    return func\n# TODO should go in an azle.py file that can be imported\n\n@query\ndef concat(x: str, y: str) -> str:\n    return x + y\n\n@query\ndef add(x: int, y: int) -> int:\n    return x + y\n\nsaved_message = ''\n\n@update\ndef record_message(message: str) -> str:\n    saved_message = message\n    return saved_message\n\n@query\ndef get_message() -> str:\n    return saved_message\n" ;

// This only executes when the canister is first installed
// We setup all of the user's code with a simple vm.run_code_string
#[ic_cdk_macros::init]
fn _kybra_init() {
    unsafe {
        let _kybra_interpreter = rustpython::vm::Interpreter::without_stdlib(Default::default());
        let _kybra_scope = _kybra_interpreter.enter(|vm| vm.new_scope_with_builtins());
        _kybra_interpreter.enter(|vm| {
            vm.run_code_string(_kybra_scope.clone(), MAIN_PY, "".to_owned())
                .unwrap();
        });
        _KYBRA_INTERPRETER_OPTION = Some(_kybra_interpreter);
        _KYBRA_SCOPE_OPTION = Some(_kybra_scope);
    }
}

// This executes when the canister is subsequently upgraded
// We setup all of the user's code with a simple vm.run_code_string
#[ic_cdk_macros::post_upgrade]
fn _kybra_post_upgrade() {
    unsafe {
        let _kybra_interpreter = rustpython::vm::Interpreter::without_stdlib(Default::default());
        let _kybra_scope = _kybra_interpreter.enter(|vm| vm.new_scope_with_builtins());
        _kybra_interpreter.enter(|vm| {
            vm.run_code_string(_kybra_scope.clone(), MAIN_PY, "".to_owned())
                .unwrap();
        });
        _KYBRA_INTERPRETER_OPTION = Some(_kybra_interpreter);
        _KYBRA_SCOPE_OPTION = Some(_kybra_scope);
    }
}
fn custom_getrandom(_buf: &mut [u8]) -> Result<(), getrandom::Error> {
    Ok(())
}
getrandom::register_custom_getrandom!(custom_getrandom);

// Query methods do not persist state, but they expect the state to be updated from the last update call
#[ic_cdk_macros::query]
#[candid::candid_method(query)]
async fn concat(x: String, y: String) -> String {
    unsafe {
        let _kybra_interpreter = _KYBRA_INTERPRETER_OPTION.as_mut().unwrap();
        let _kybra_scope = _KYBRA_SCOPE_OPTION.as_mut().unwrap();
        let result = _kybra_interpreter.enter(|vm| {
            let method_py_object_ref = _kybra_scope.globals.get_item("concat", vm).unwrap();
            let result_py_object_ref = vm
                .invoke(
                    &method_py_object_ref,
                    (
                        x.try_into_vm_value(vm).unwrap(),
                        y.try_into_vm_value(vm).unwrap(),
                    ),
                )
                .unwrap();
            result_py_object_ref.try_from_vm_value(vm).unwrap()
        });
        result
    }
}

// Query methods do not persist state, but they expect the state to be updated from the last update call
#[ic_cdk_macros::query]
#[candid::candid_method(query)]
async fn add(x: i128, y: i128) -> i128 {
    unsafe {
        let _kybra_interpreter = _KYBRA_INTERPRETER_OPTION.as_mut().unwrap();
        let _kybra_scope = _KYBRA_SCOPE_OPTION.as_mut().unwrap();
        let result = _kybra_interpreter.enter(|vm| {
            let method_py_object_ref = _kybra_scope.globals.get_item("add", vm).unwrap();
            let result_py_object_ref = vm
                .invoke(
                    &method_py_object_ref,
                    (
                        x.try_into_vm_value(vm).unwrap(),
                        y.try_into_vm_value(vm).unwrap(),
                    ),
                )
                .unwrap();
            result_py_object_ref.try_from_vm_value(vm).unwrap()
        });
        result
    }
}

// Update methods MUST persist state
// Notice that we need to take all of the user-provided arguments and pass them in as function arguments to the Python function
// Notice that the Rust code is effectively acting as a proxy to the Python code
#[ic_cdk_macros::update]
#[candid::candid_method(update)]
async fn record_message(message: String) -> String {
    unsafe {
        let _kybra_interpreter = _KYBRA_INTERPRETER_OPTION.as_mut().unwrap();
        let _kybra_scope = _KYBRA_SCOPE_OPTION.as_mut().unwrap();
        let result = _kybra_interpreter.enter(|vm| {
            let method_py_object_ref = _kybra_scope.globals.get_item("record_message", vm).unwrap();
            let result_py_object_ref = vm
                .invoke(
                    &method_py_object_ref,
                    (message.try_into_vm_value(vm).unwrap(),),
                )
                .unwrap();
            result_py_object_ref.try_from_vm_value(vm).unwrap()
        });
        result
    }
}
#[ic_cdk_macros::query]
#[candid::candid_method(query)]
async fn get_message() -> String {
    unsafe {
        let _kybra_interpreter = _KYBRA_INTERPRETER_OPTION.as_mut().unwrap();
        let _kybra_scope = _KYBRA_SCOPE_OPTION.as_mut().unwrap();
        let result = _kybra_interpreter.enter(|vm| {
            let method_py_object_ref = _kybra_scope.globals.get_item("get_message", vm).unwrap();
            let result_py_object_ref = vm.invoke(&method_py_object_ref, ()).unwrap();
            result_py_object_ref.try_from_vm_value(vm).unwrap()
        });
        result
    }
}
candid::export_service!();
#[ic_cdk_macros::query(name = "__get_candid_interface_tmp_hack")]
fn export_candid() -> String {
    __export_service()
}
#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn write_candid_to_disk() {
        std::fs::write("main.did", export_candid()).unwrap();
    }
}

@lastmjs
Copy link
Author

lastmjs commented Sep 22, 2022

As a first step I'm just trying to get this to work within a single interpreter.enter not using scope:

message = 'it did not work'

def set():
    message = 'it did work'
    return message

def get():
    return message

Here's the Rust code:

fn main() {
    let interpreter = rustpython::vm::Interpreter::with_init(Default::default(), |vm| {

        vm.add_frozen(rustpython::vm::py_freeze!(
            file = "src/main.py",
            module_name = "user_code"
        ));
    });

    interpreter.enter(|vm| {
        let module_result = vm.invoke(&vm.import_func, ("user_code",)).unwrap();

        let set = module_result.get_attr("set", vm).unwrap();

        vm.invoke(&set, ()).unwrap();

        let get = module_result.get_attr("get", vm).unwrap();

        let get_result = vm.invoke(&get, ()).unwrap();

        let get_result_string: String = get_result.try_into_value(vm).unwrap();

        println!("get_result_string: {}", get_result_string);
    });
}

I've loaded the main.py as a frozen module.
Then I've imported it (this is the best I've been able to figure out how to do).
And now I'm trying to call functions and just get the message to change and get updated.
It doesn't work, whenever I call get() I always get 'it did not work'.

@lastmjs
Copy link
Author

lastmjs commented Sep 22, 2022

Okay so this is really dumb, but I just needed to use the global keyword in my original code earlier up in this issue. This seems to be working well so far, I'll keep an eye on it.

I would love some suggestions on how to possibly do this better though, my next steps are to compile or freeze my modules and I'm hoping it will transfer over well enough.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants