Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 30 additions & 15 deletions rs/messaging/src/routing/stream_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ struct StreamBuilderMetrics {
pub routed_payload_sizes: Histogram,
/// Misrouted messages currently in streams, by remote subnet.
pub stream_misrouted_messages: IntGaugeVec,
/// Canister output queues skipped because this subnet or their destination
/// subnet was cooling down.
/// Output queues skipped because this subnet or their destination subnet was
/// cooling down.
pub cooling_down_skipped_queues: IntCounter,
/// Critical error for payloads above the maximum supported size.
pub critical_error_payload_too_large: IntCounter,
Expand Down Expand Up @@ -132,8 +132,8 @@ impl StreamBuilderMetrics {
);
let cooling_down_skipped_queues = metrics_registry.int_counter(
METRIC_COOLING_DOWN_SKIPPED_QUEUES,
"Canister output queues skipped because this subnet or their destination subnet \
was cooling down. Counted once per queue per round, so the same queue is counted \
"Output queues skipped because this subnet or their destination subnet was \
cooling down. Counted once per queue per round, so the same queue is counted \
repeatedly for as long as either subnet keeps cooling down.",
);
let critical_error_payload_too_large =
Expand Down Expand Up @@ -454,8 +454,10 @@ impl StreamBuilderImpl {
self.route_refunds(&mut state, refund_limit, &network_topology, &mut streams);

// No canister can have the subnet's own principal as its canister ID, so this
// identifies the messages taken from the subnet's own output queues.
// identifies the messages taken from the subnet's own output queues. Those are
// only ever responses, as the management canister makes no calls of its own.
let own_subnet_as_canister_id = CanisterId::from(self.subnet_id);

let own_subnet_is_cooling_down = network_topology.is_cooling_down(&self.subnet_id);

let mut requests_to_reject = Vec::new();
Expand All @@ -474,7 +476,7 @@ impl StreamBuilderImpl {
// Cheap to clone, `RequestOrResponse` wraps `Arcs`.
let msg = msg.clone();

let is_from_subnet_queues = msg.sender() == own_subnet_as_canister_id;
let is_subnet_output_response = msg.sender() == own_subnet_as_canister_id;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ultimate nitpicking: You could also say msg.sender().get() == self.subnet_id.get(). Then you wouldn't need an explicit own_subnet_as_canister_id.

And regardless, you should probably move own_subnet_as_canister_id's comment here, as it's more relevant here.


match network_topology.route(msg.receiver().get()) {
// Destination subnet found.
Expand All @@ -483,15 +485,28 @@ impl StreamBuilderImpl {
let dst_subnet_topology = network_topology.subnets().get(&dst_subnet_id);
let dst_subnet_type = dst_subnet_topology.map(|topology| topology.subnet_type);

// No messages from canister output queues are routed while either
// this subnet (the source) or the destination subnet is cooling down;
// not even into the loopback stream. Retain the message (along with
// everything behind it in the same queue) until neither subnet is
// cooling down anymore, rather than rejecting or dropping it.
if !is_from_subnet_queues
&& (own_subnet_is_cooling_down
|| dst_subnet_topology.is_some_and(|topology| topology.cooling_down))
{
let dst_subnet_is_cooling_down =
dst_subnet_topology.is_some_and(|topology| topology.cooling_down);

// No messages from canister output queues are routed while either this
// subnet (the source) or the destination subnet is cooling down; not
// even into the loopback stream.
//
// The subnet's own output responses are exempt for as long as this
// subnet is cooling down, so that it can still respond to the calls it
// has already accepted; whether or not the destination subnet is cooling
// down, the loopback stream included. They are only held back if this
// subnet is not cooling down but the destination subnet is.
Comment on lines +495 to +499

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// The subnet's own output responses are exempt for as long as this
// subnet is cooling down, so that it can still respond to the calls it
// has already accepted; whether or not the destination subnet is cooling
// down, the loopback stream included. They are only held back if this
// subnet is not cooling down but the destination subnet is.
// Subnet output queues of cooling down subnets (only holding responses) are
// exempt, so they can deliver responses to all the calls they have already
// accepted.

You can optionally add "... deliver responses (before the subnet is deleted) to all ...".

//
// Retain the message (along with everything behind it in the same queue)
// until the subnet that holds it back stops cooling down, rather than
// rejecting or dropping it.
let skip_while_cooling_down = if is_subnet_output_response {
!own_subnet_is_cooling_down && dst_subnet_is_cooling_down
} else {
own_subnet_is_cooling_down || dst_subnet_is_cooling_down
};
if skip_while_cooling_down {
self.metrics.cooling_down_skipped_queues.inc();
output_iter.exclude_queue();
continue;
Expand Down
168 changes: 167 additions & 1 deletion rs/messaging/src/routing/stream_builder/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1164,7 +1164,8 @@ mod cooling_down {
/// cooling down subnets are held back.
const OTHER_SUBNET: SubnetId = SUBNET_3;

/// The sender of all messages in the tests below, hosted by `LOCAL_SUBNET`.
/// The sender of all canister messages in the tests below, hosted by
/// `LOCAL_SUBNET`.
const SENDER_CANISTER: CanisterId = CanisterId::from_u64(0);
/// The destination canister, hosted by the cooling down subnet (which is
/// `LOCAL_SUBNET` itself in `new_local_cooling_down_fixture()`).
Expand Down Expand Up @@ -1240,6 +1241,17 @@ mod cooling_down {
});
}

/// Marks `subnet_id` as cooling down.
fn mark_cooling_down(state: &mut ReplicatedState, subnet_id: SubnetId) {
state.metadata.modify_network_topology(|network_topology| {
network_topology
.subnets_mut()
.get_mut(&subnet_id)
.unwrap()
.cooling_down = true;
});
}

/// A request from `SENDER_CANISTER` to `receiver`, with the given callback ID.
///
/// `canister_states_with_outputs()` requires the callback IDs of a canister's
Expand Down Expand Up @@ -1310,6 +1322,56 @@ mod cooling_down {
})
}

/// The matrix of messages from `LOCAL_SUBNET`'s own output queues to
/// `originator` covered by the cooling down tests: unbounded-wait or
/// bounded-wait; with no cycles or 1T cycles attached.
///
/// Responses only: the management canister makes no calls of its own, so a
/// request can never be found in the subnet's own output queues.
fn cooling_down_subnet_message_matrix(
originator: CanisterId,
) -> impl Iterator<Item = Arc<Response>> {
[
(NO_DEADLINE, Cycles::zero()),
(NO_DEADLINE, ONE_TRILLION_CYCLES),
(SOME_DEADLINE, Cycles::zero()),
(SOME_DEADLINE, ONE_TRILLION_CYCLES),
]
.into_iter()
.map(move |(deadline, refund)| {
Arc::new(Response {
originator,
respondent: CanisterId::from(LOCAL_SUBNET),
originator_reply_callback: CallbackId::from(1),
refund,
response_payload: Payload::Data(vec![]),
deadline,
})
})
}

/// Enqueues `response` into `LOCAL_SUBNET`'s own output queues, first pushing
/// then popping the request it is a response to (which is what reserves the
/// output queue slot).
fn push_subnet_output_response(state: &mut ReplicatedState, response: Arc<Response>) {
state
.push_input(
RequestBuilder::new()
.sender(response.originator)
.receiver(response.respondent)
.sender_reply_callback(response.originator_reply_callback)
.payment(response.refund)
.deadline(response.deadline)
.build()
.into(),
&mut (i64::MAX / 2),
)
.unwrap();
state.pop_subnet_input().unwrap();

state.subnet_queues_mut().push_output_response(response);
}

/// Asserts that no canister message was routed into the stream to `subnet_id`.
fn assert_no_messages_routed(state: &ReplicatedState, subnet_id: SubnetId) {
assert_eq!(
Expand Down Expand Up @@ -1350,6 +1412,20 @@ mod cooling_down {
.collect()
}

/// Returns the raw contents of the subnet's own output queue to `receiver`.
fn subnet_output_queue_contents(
state: &ReplicatedState,
receiver: CanisterId,
) -> Vec<RequestOrResponse> {
state
.subnet_queues()
.output_queue_iter_for_testing(&receiver)
.into_iter()
.flatten()
.cloned()
.collect()
}

/// Retrieves the `METRIC_COOLING_DOWN_SKIPPED_QUEUES` counter's value.
fn fetch_cooling_down_skipped_queues(metrics_registry: &MetricsRegistry) -> u64 {
fetch_int_counter(metrics_registry, METRIC_COOLING_DOWN_SKIPPED_QUEUES)
Expand Down Expand Up @@ -1523,6 +1599,96 @@ mod cooling_down {
}
}
}

/// Tests that a response in the subnet's own output queues addressed to a
/// canister on a cooling down subnet is retained there -- rather than routed,
/// rejected or dropped -- while `LOCAL_SUBNET` is not cooling down; and that it
/// is routed as soon as the destination subnet stops cooling down.
///
/// Covers the full matrix of: unbounded-wait vs. bounded-wait; and with no
/// cycles vs. 1T cycles attached.
///
/// Contrast with `build_streams_routes_subnet_messages_while_cooling_down()`,
/// where `LOCAL_SUBNET` is cooling down and the response is routed regardless.
#[test]
fn build_streams_retains_subnet_messages_to_cooling_down_subnet() {
for response in cooling_down_subnet_message_matrix(COOLING_DOWN_CANISTER) {
with_test_replica_logger(|log| {
let (stream_builder, mut provided_state, metrics_registry) =
new_cooling_down_fixture(&log);
push_subnet_output_response(&mut provided_state, response.clone());

let mut result_state = stream_builder.build_streams(provided_state);

// Nothing was routed into the stream to the cooling down subnet and the
// response is still in the subnet's own output queue.
assert_no_messages_routed(&result_state, COOLING_DOWN_SUBNET);
assert_eq!(
vec![RequestOrResponse::Response(response.clone())],
subnet_output_queue_contents(&result_state, COOLING_DOWN_CANISTER)
);

assert_routed_messages_eq(MetricVec::new(), &metrics_registry);
assert_eq!(1, fetch_cooling_down_skipped_queues(&metrics_registry));
assert_eq_critical_errors(0, 0, 0, &metrics_registry);

// And it is routed as soon as the destination subnet stops cooling down.
clear_cooling_down(&mut result_state, COOLING_DOWN_SUBNET);
let result_state = stream_builder.build_streams(result_state);
assert_eq!(
vec![StreamMessage::from(RequestOrResponse::Response(response))],
routed_messages(&result_state, COOLING_DOWN_SUBNET)
);
});
}
}

/// Tests that a response in `LOCAL_SUBNET`'s own output queues is routed while
/// `LOCAL_SUBNET` itself is cooling down -- so that a cooling down subnet can
/// still respond to the calls it has already accepted -- whether or not the
/// destination subnet is cooling down, the loopback stream included.
#[test]
fn build_streams_routes_subnet_messages_while_cooling_down() {
for (originator, dst_subnet) in [
// A canister hosted by `OTHER_SUBNET`, which is not cooling down.
(OTHER_CANISTER, OTHER_SUBNET),
// A canister hosted by the remote `COOLING_DOWN_SUBNET`.
(COOLING_DOWN_CANISTER, COOLING_DOWN_SUBNET),
// A canister hosted by `LOCAL_SUBNET` itself, i.e. the loopback stream;
// and `LOCAL_SUBNET` is cooling down.
(SENDER_CANISTER, LOCAL_SUBNET),
] {
for response in cooling_down_subnet_message_matrix(originator) {
with_test_replica_logger(|log| {
let (stream_builder, mut provided_state, metrics_registry) =
new_cooling_down_fixture(&log);
mark_cooling_down(&mut provided_state, LOCAL_SUBNET);
push_subnet_output_response(&mut provided_state, response.clone());

let result_state = stream_builder.build_streams(provided_state);

assert_eq!(
vec![StreamMessage::from(RequestOrResponse::Response(response))],
routed_messages(&result_state, dst_subnet)
);
assert!(subnet_output_queue_contents(&result_state, originator).is_empty());

assert_routed_messages_eq(
metric_vec(&[(
&[
(LABEL_TYPE, LABEL_VALUE_TYPE_RESPONSE),
(LABEL_STATUS, LABEL_VALUE_STATUS_SUCCESS),
],
1,
)]),
&metrics_registry,
);
assert_eq!(0, fetch_cooling_down_skipped_queues(&metrics_registry));
assert_eq_critical_errors(0, 0, 0, &metrics_registry);
});
}
}
}
}

/// Given a stream with some (potentially zero) initial refunds and canister
Expand Down
Loading