That sounds like a nerf…hmmmm….how do you provide private data if you don’t have this? Can we have an original_caller that is passed along?
Thanks for the idea, @claudio. I haven’t considered this option.
Right now it is not clear whether we can manage to get secure caller_id
in cross-subnet calls or not.
I think we have the following options:
A. We manage to make caller_id
secure in cross-subnet calls. There is an idea by @bogwar on how to support secure/repeated cross-subnet queries with replication factor of f+1
in a subnet with 3f+1
nodes. That would be something between non-replicated and fully replicated execution mode. I’ll write up a detailed explanation of the idea.
B. We are not able to make caller_id secure in cross-subnet calls.
- B.1 [dynamic caller-id]: Replace
caller_id
with the anonymous principal is cross-subnet calls, but keep it valid in same-subnet calls. - B.2 [caller-id signed by node]: Follow @nomeata’s suggestion that there is no critical regression in security if the node is able to query what any canister that it is hosting can query.
- B.3 [disable caller-id for composite queries]. This is your suggestion.
The plan is to make option A work because that maximises the usefulness of composite queries. If that fails then fall back to B.1 or B.2.
As @skilesare mentioned, it seems that option B.3. will greatly reduce the usefulness of composite queries even in cases when the caller_id
is guaranteed to be secure (same subnet calls and calls from users).
B.1 breaks with the goal that subnet placement is transparent as far as possible (which, I know, is under pressure anyways, but hey). B.3 may be too restrictive. I still stand by my suggestion of B.2!
Thanks @ulan.
Actually, just to be clear, my main suggestion was to prevent regular queries from being invoked from composite queries, so that a regular query can continue to reliably inspect its caller. Or maybe that was never the case in the first place if a malicious replica can always fake that information anyway.
I still stand by my suggestion of B.2!
Thanks for confirming that your choice didn’t change.
Actually, just to be clear, my main suggestion was to prevent regular queries from being invoked from composite queries, so that a regular query can continue to reliably inspect its caller.
Yep, I got it and I think that would reduce usefulness of composite queries because most of the queries exported by canisters will be regular queries. The developers that want to compose these canisters would need to wait until the controllers change the regular queries to composite query. But that change would come with the downside of making the query not callable by updates (until we get replicated mode support for composite queries).
If composite queries can call regular quereis, then choosing the query type would be much simpler:
-
if the query doesn’t call other queries, then make it a regular query.
-
if the query calls other queries, then make it a composite query.
@claudio and I have been discussing recursive composite query calls. We need your input to decide what is the most natural (least surprising) semantics for developers.
Please take a look at this example, where we have two canisters: A and B. Each canister has a global counter set to 0 and a composite query method inc_counter()
that increments the counter and returns its new value. Additionally, canister B has two composite query methods: call_a()
and call_b()
.
What would you expect the queries to return when invoked by a user (e.g. using dfx
).
Canister A:
counter: int32 = 0; // <= Global variable
#[composite_query]
fn inc_counter() -> int32 {
counter += 1;
return counter;
}
Canister B:
counter: int32 = 0; // <= Global variable
#[composite_query]
fn inc_counter() -> int32 {
counter += 1;
return counter;
}
#[composite_query]
fn call_a() -> int32 {
counter += 1;
// Canister B calls canister A twice.
let a1 = call("Canister A", "inc_counter").await;
let a2 = call("Canister A", "inc_counter").await;
return counter + a1 + a2;
}
#[composite_query]
fn call_b() -> int32 {
counter += 1;
// Canister B calls canister B (self) twice.
let b1 = call("Canister B", "inc_counter").await;
let b2 = call("Canister B", "inc_counter").await;
return counter + b1 + b2;
}
Option 1
Composite queries are fully isolated from each other (do not see each other’s changes):
-
A.inc_counter()
returns1
. -
B.inc_counter()
returns1
. -
B.call_a()
returns3 (=1+1+1)
. -
B.call_b()
returns3 (=1+1+1)
.
Option 2
Composite queries see each other’s changes if they are in the same call graph:
-
A.inc_counter()
returns1
. -
B.inc_counter()
returns1
. -
B.call_a()
returns4 (=1+1+2)
. That is:counter=1
,b1=1
,b2=2
. -
B.call_b()
returns8 (=3+2+3)
. That is:counter=3
,a1=2
,a2=3
.
Option 3
Composite queries are isolated except when they are recursive:
-
A.inc_counter()
returns1
. -
B.inc_counter()
returns1
. -
B.call_a()
returns3 (=1+1+1)
. -
B.call_b()
returns8 (=3+2+3)
.
Option 4
Composite queries are isolated and recursion is not allowed:
-
A.inc_counter()
returns1
. -
B.inc_counter()
returns1
. -
B.call_a()
returns3 (=1+1+1)
. -
B.call_b()
returnsError: recursive calls are not allowed
.
Tagging everyone how commented in this thread: @skilesare, @Manu, @paulyoung, @jzxchiang, @lastmjs, @Seb, @nomeata.
I’d expect Option 1: It behaves the same as if canister B was not on the IC, but rather off the IC (remember the original vision of canisters living on and off chain and still being able to communicate).
Or put differently: An inter-canister query call behaves like a external query call, and behaves the same when done from an update method or a composite query method.
I also think Option 1 is most natural and easiest to understand.
Thanks for seeking our feedbacks!
I would say Option 1 as according to the explicit contract on the IC, queries should discard their states. So if you need to pass a current query computation, you just pass it as an argument.
That being said, I understand that this might be the most limiting implementation though
Option 1 is the perhaps the most readily understandable, but Option 2 is the most useful and makes the most sense when considering the collection of canisters as representing a single logical computational environment. Option 2 enables A → B → A calls where A is managing some large state and uses B as a stateful service which may require some portion of the A state (which it doesn’t know a priori). For example, if A is processing a DB query with some proposed update and B is a service which is validating some aspect of the overall DB it would need to query A against the proposed updates already represented in A’s memory space. This applies all the way down the call stack as portions of the call tree may wish to cache or incrementally build data structures necessary to respond to the query. While Option 1 makes sense it isn’t nearly as useful and becomes unintuitive when the user attempts to refactor their code across canisters e.g. for space or parallelism reasons. At that point, moving functions and objects changes the result of the computation in non-intuitive ways.
I want to first start off by saying the update of state in a composable query is confusing since queries should not change state.
Option 1 reverts the state back to origin as the same method is actor A or B is called. Thereby, each caller will get the same response. If you are just querying data I think this makes the most sense.
Option 4 is similar except it doesn’t allow for any method calls of itself. As long as the dev doesn’t call a function that might never terminate I think it is good to allow an actor to call a method of itself.
Option 2 is just forcing recursion on all the calls. However not sure we want to force recursion without allowing default non recursion use case.
Option 3 is probably the best case for recursion. You allow it but only when an actor is calling itself. So the dev knows that function will be recursive no matter what.
I would stay away from recursion as default since it is more difficult to reason about. Even going through those simple examples it was hard to understand what was happening.
For example, if A is processing a DB query with some proposed update
I’m not sure I understand why a DB query would contain a proposed update. If the user is calling a composite_query
, I think it is fair to assume they are not trying to store or mutate data.
Maybe a dumb question here, but can the composite query call a regular function that isn’t async? I definitely want to be able to call various functions and accumulate a set of data. I’m fine with the accumulator being limited to a snapshot in time without being able to change state, but I do need to bob and weave through the a number of objects and functions to build a “report”.
I’d love @icme 's take as I’d imagine accumulators that could function across queries would be great for complex queries.
can the composite query call a regular function that isn’t async?
Yes, a call to a regular (synchronous) function will be compiled as part of the same composite query. So the function will behave as expected in traditional programming: all global changes are visible and propagated to the caller. BTW, this already works in existing regular queries since they also can call regular functions.
I do need to bob and weave through the a number of objects and functions to build a “report”.
For regular (sync) functions this would not be a problem. For queries, I would suggest a map-reduce pattern that would work with all the options proposed above:
#[composite_query]
fn generate_report() -> Report {
let report = Report::new();
for callee in canisters {
let result = call(canisters, "generate_sub_report").await;
report.add(result);
}
return report;
}
That works well if both A and B are part of the same service. I am worried about the cases where A and B are completely independent services written by different developers. Then the reentrant B → A query might not expect changes in the global state of A done by the initial query.
To give a concrete example: let’s say A is a booking service canister and B is a hotel canister. The user calls A.find_hotel(user, date)
, which calls B.find_room(user, date)
, which calls A.get_user_info(user)
.
Option 2 would execute A.get_user_info()
against the intermediate state that has changes from A.find_hotel()
that may break A.get_user_info()
. (The developer A might have assumed that it is okay to destroy the global state of A in A.find_hotel()
because the query state changes are discarded anyways).
To me option 1 makes most sense because query calls should be scoped and should not be allowed to update the canister state.
I purposely didn’t read any other feedback before answering and the Option 1 seems more natural to me.
Why not accumulate data through a single canister?
So let’s say we have something like Canister A, B, C, and D where the public facing endpoint is in canister A and the data that needs reducing is in Canisters B, C, and D
- Caller queries Canister A
- A queries B, C, and D
- A receives responses from B, C, and D and then reduces/accumulates the result
- A returns the accumulated result to the original caller
@ulan it also seems like if the IC allows the holding of intermediate state with composite queries, then if inter-canister composite query closed-loop cycles exist between canisters (A → B → A), then the state tree could expand significantly (needing to hold each commit point of the previous composite query). So I’m pretty in favor of option 1 - with one caveat.
…specifically this part of option 4:
I haven’t tried recursive canister update calls out yet, so I’m actually interested if their current implementation is at all close to what heartbeat is doing.
Can you or anyone else provide a use case for allowing recursive canister self calls? If there isn’t a good use case, then it might be best to just block recursive calls and go forward with option 4 for now. I’d imagine that starting out with recursive calls being blocked also has the benefit of being a backwards compatible change if we then later on decide to allow recursive calls, whereas the reverse (allowing recursive calls at the start) is backwards-incompatible and a breaking change.
then the state tree could expand significantly
Yes, I expect the peak memory usage of Option 2 to be much higher than the peak memory usage of Option 1. (The former is proportional to the size of the call graph whereas the latter is proportional to the depths of the call graph.)
Can you or anyone else provide a use case for allowing recursive canister self calls?
I think the recursive calls will appear mostly “accidentally” if multiple independent services rely on each other. See my example above: Proposal: Composite Queries - #36 by ulan
@claudio mentioned that Option 2 would be useful for implementing anonymous async blocks in composite queries using self-calls.
I’m moreso talking about the case where you have B → B recursive self calls. Is this the case being described here when you talk about recursive self calls?
From my understanding Option 4 allows A → B → A (with intermediate state being discarded), but does not allow B → direct loop to → B calls.
As an aside, in some of my Motoko code I have patterns like:
// main.mo
import H "./doHelper";
actor {
public func doIt(): async () {
await H.doThis();
await H.doThat();
};
public func sometimesDoIt(): async () {
if (conditionIsMet) {
await doIt()
};
}
};
// doHelper.mo
module {
public func doThis() {
await asyncTask1();
};
public func doThat() {
await asyncTask2();
};
};
Would option 4 break any of these use cases? Specifically the ones where async helper functions are called, or sometimesDoIt()
calls doIt()
?
If it would break this, then I’d vote for option 1.