Skip to content

Commit e0d1486

Browse files
eichhorlIDX GitHub Automation
andauthored
feat: CON-1639 HTTPS outcalls pay-as-you-go and dark launch budget trackers (#10519)
## Background Currently, HTTPS outcalls are charged upfront based on a `max_response_bytes` parameter. This is done by subtracting the full cost from the caller's payment. The remaining cycles are stored in the request context and refunded once a response is delivered. Instead, we want to introduce pay-as-you-go pricing, which charges cycles whenever resources are consumed. This happens in three stages: 1. Base cost. This fee is charged for every request upfront 2. Per-replica cost. The remaining cycles after charging the base cost are split evenly between the participating replicas. Each replica consumes some of their allowance as the HTTP request is processed. In the end, the remaining cycles are gossiped as part of refund shares. 3. Consensus cost. This fee is charged for including the aggregated HTTP response as part of a block. The cost is covered by the sum of refund shares that were included in the aggregated response. Cycles remaining after charging the consensus cost are refunded to the user asynchronously. The per-replica cost (2.) is calculated by the "budget tracker": This struct is instantiated with the per-replica cycles allowance, whenever a new request is starting to be processed by the HTTP adapter. As the response is downloaded, transformed and gossiped, the tracker charges the consumed cycles from the initial allowance. If at any point the remaining allowance _does not_ cover an outstanding charge, an error is returned and a reject response is gossiped. If the initial allowance _does_ cover all charges, the remaining cycles are refunded. To this end, the budget tracker creates a payment receipt which is gossiped alongside the response. Before this PR only one budget tracker exited (`LegacyTracker`) which doesn't compute any per-replica cost, and also doesn't refund anything. ## Proposed Changes This PR introduces the "pay-as-you-go" budget tracker, whose purpose is to calculate the per-replica cost for outcalls using the "pay-as-you-go" pricing (note that such outcalls do not exist yet). This is done by implementing the per-replica part of the pricing formula defined [here](https://colab.research.google.com/drive/1MyZBivzU_5kQtqQ8Z71gYbrMubGrTXmv#scrollTo=vxa3d-NYyEOi) (internal). To do this, we additionally pass the subnet's size and cycle cost schedule to the HTTP adapter. This is needed to calculate the correct pricing. "Pay-as-you-go" pricing charges for the amount of bytes that are gossiped explicitly in the case of flexible and non-replicated outcalls (where the whole response is gossiped). To do this, we move the truncation of oversized reject messages into the adapter, such that the correct payload length is charged. Additionally, we implement and start to use a `DarkLaunchTracker`. This tracker calculates both, the real (legacy, i.e. 0) and the new (pay-as-you-go) per-replica cost. In the end, only the "real" refund is gossiped. However, this allows us to compare both trackers, and observe whenever the pay-as-you-go tracker returns an out-of-cycles error, while the legacy tracker succeeds. Such an event indicates that the outcall would not be covered by enough cycles under the new pricing. In this case, the canister ID is logged and a metric increased. The legacy charging flow should be unchanged by this PR. --------- Co-authored-by: IDX GitHub Automation <infra+github-automation@dfinity.org>
1 parent 82ff6e5 commit e0d1486

18 files changed

Lines changed: 1034 additions & 322 deletions

File tree

Cargo.lock

Lines changed: 6 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rs/https_outcalls/client/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ rust_library(
2525
"//rs/monitoring/metrics",
2626
"//rs/types/management_canister_types",
2727
"//rs/types/types",
28+
"//rs/utils",
2829
"@crate_index//:candid",
2930
"@crate_index//:futures",
3031
"@crate_index//:hyper-util",
@@ -56,6 +57,7 @@ rust_test(
5657
"//rs/test_utilities/types",
5758
"//rs/types/management_canister_types",
5859
"//rs/types/types",
60+
"//rs/utils",
5961
"@crate_index//:candid",
6062
"@crate_index//:futures",
6163
"@crate_index//:hyper-util",

rs/https_outcalls/client/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ ic-interfaces-adapter-client = { path = "../../interfaces/adapter_client" }
2323
ic-logger = { path = "../../monitoring/logger" }
2424
ic-metrics = { path = "../../monitoring/metrics" }
2525
ic-types = { path = "../../types/types" }
26+
ic-utils = { path = "../../utils" }
2627
prometheus = { workspace = true }
2728
slog = { workspace = true }
2829
tokio = { workspace = true }

rs/https_outcalls/client/src/client.rs

Lines changed: 162 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,17 @@ use ic_logger::{ReplicaLogger, info, warn};
1515
use ic_management_canister_types_private::{CanisterHttpResponsePayload, TransformArgs};
1616
use ic_metrics::MetricsRegistry;
1717
use ic_types::{
18-
CanisterId, NumBytes, NumInstructions,
18+
CanisterId, CountBytes, NumBytes, NumInstructions,
1919
canister_http::{
2020
CanisterHttpHeader, CanisterHttpMethod, CanisterHttpPaymentReceipt, CanisterHttpReject,
2121
CanisterHttpRequest, CanisterHttpRequestContext, CanisterHttpResponse,
22-
CanisterHttpResponseContent, Transform, validate_http_headers_and_body,
22+
CanisterHttpResponseContent, MAXIMUM_CANISTER_HTTP_ERROR_MESSAGE_BYTES, Transform,
23+
validate_http_headers_and_body,
2324
},
2425
ingress::WasmResult,
2526
messages::{Query, QuerySource, Request},
2627
};
28+
use ic_utils::str::StrEllipsize;
2729
use std::{
2830
sync::{Arc, atomic::AtomicU64},
2931
time::{Duration, Instant},
@@ -65,6 +67,7 @@ pub struct CanisterHttpAdapterClientImpl {
6567
rx: Receiver<(CanisterHttpResponse, CanisterHttpPaymentReceipt)>,
6668
query_service: TransformExecutionService,
6769
metrics: Metrics,
70+
pricing_factory: PricingFactory,
6871
log: ReplicaLogger,
6972
}
7073

@@ -79,13 +82,15 @@ impl CanisterHttpAdapterClientImpl {
7982
) -> Self {
8083
let (tx, rx) = channel(inflight_requests);
8184
let metrics = Metrics::new(&metrics_registry);
85+
let pricing_factory = PricingFactory::new(&metrics_registry, log.clone());
8286
Self {
8387
rt_handle,
8488
grpc_channel,
8589
tx,
8690
rx,
8791
query_service,
8892
metrics,
93+
pricing_factory,
8994
log,
9095
}
9196
}
@@ -121,6 +126,7 @@ impl NonBlockingChannel<CanisterHttpRequest> for CanisterHttpAdapterClientImpl {
121126
let mut http_adapter_client = HttpsOutcallsServiceClient::new(self.grpc_channel.clone());
122127
let query_handler = self.query_service.clone();
123128
let metrics = self.metrics.clone();
129+
let pricing_factory = self.pricing_factory.clone();
124130
let log = self.log.clone();
125131

126132
// Spawn an async task that sends the canister http request to the adapter and awaits the response.
@@ -131,9 +137,12 @@ impl NonBlockingChannel<CanisterHttpRequest> for CanisterHttpAdapterClientImpl {
131137
id: request_id,
132138
context: request_context,
133139
socks_proxy_addrs,
140+
cost_schedule,
141+
subnet_size,
134142
} = canister_http_request;
135143

136-
let mut budget = PricingFactory::new_tracker(&request_context);
144+
let mut budget =
145+
pricing_factory.new_tracker(&request_context, subnet_size, cost_schedule);
137146
let request_size = request_context.variable_parts_size();
138147

139148
let CanisterHttpRequestContext {
@@ -149,6 +158,7 @@ impl NonBlockingChannel<CanisterHttpRequest> for CanisterHttpAdapterClientImpl {
149158
http_method: request_http_method,
150159
transform: request_transform,
151160
pricing_version: request_pricing_version,
161+
replication: request_replication,
152162
..
153163
} = request_context;
154164

@@ -177,7 +187,7 @@ impl NonBlockingChannel<CanisterHttpRequest> for CanisterHttpAdapterClientImpl {
177187
return;
178188
}
179189

180-
let payload = async {
190+
let mut payload = async {
181191
// Execute the HTTP request and get the adapter response.
182192
let (adapter_response, downloaded_bytes, elapsed) = execute_http_request(
183193
&mut http_adapter_client,
@@ -262,9 +272,42 @@ impl NonBlockingChannel<CanisterHttpRequest> for CanisterHttpAdapterClientImpl {
262272
}
263273
.await;
264274

275+
// Truncate an oversized reject message before pricing and gossiping
276+
// it, so the gossip cost reflects what is actually gossiped.
277+
if let Err(reject) = &mut payload
278+
&& reject.message.len() > MAXIMUM_CANISTER_HTTP_ERROR_MESSAGE_BYTES
279+
{
280+
warn!(
281+
log,
282+
"Pruning oversized reject message for request {}: \
283+
original size {}, new size {}",
284+
request_id,
285+
reject.message.len(),
286+
MAXIMUM_CANISTER_HTTP_ERROR_MESSAGE_BYTES,
287+
);
288+
reject.message = reject
289+
.message
290+
.ellipsize(MAXIMUM_CANISTER_HTTP_ERROR_MESSAGE_BYTES, 90);
291+
}
292+
293+
// Account for the cost of gossiping the final (post-transform)
294+
// response to peers before creating the receipt.
295+
let response_size = match &payload {
296+
Ok(response) => response.len(),
297+
Err(reject) => reject.count_bytes(),
298+
};
299+
let payload = budget
300+
.subtract_gossip_usage(NumBytes::from(response_size as u64))
301+
.map_err(|PricingError::InsufficientCycles| CanisterHttpReject {
302+
reject_code: RejectCode::CanisterReject,
303+
message: "Insufficient cycles".to_string(),
304+
})
305+
.and(payload);
306+
265307
// Create the payment receipt after all processing is complete.
266308
let receipt = budget.create_payment_receipt();
267309

310+
let replication = request_replication.kind();
268311
permit.send((
269312
CanisterHttpResponse {
270313
id: request_id,
@@ -273,7 +316,11 @@ impl NonBlockingChannel<CanisterHttpRequest> for CanisterHttpAdapterClientImpl {
273316
Ok(resp) => {
274317
metrics
275318
.request_total
276-
.with_label_values(&["success", request_http_method.as_str()])
319+
.with_label_values(&[
320+
"success",
321+
request_http_method.as_str(),
322+
replication.as_str(),
323+
])
277324
.inc();
278325
CanisterHttpResponseContent::Success(resp)
279326
}
@@ -283,6 +330,7 @@ impl NonBlockingChannel<CanisterHttpRequest> for CanisterHttpAdapterClientImpl {
283330
.with_label_values(&[
284331
reject.reject_code.as_str(),
285332
request_http_method.as_str(),
333+
replication.as_str(),
286334
])
287335
.inc();
288336
CanisterHttpResponseContent::Reject(reject)
@@ -372,7 +420,7 @@ async fn execute_http_request(
372420
response_time: elapsed,
373421
})
374422
.map_err(|PricingError::InsufficientCycles| CanisterHttpReject {
375-
reject_code: RejectCode::SysFatal,
423+
reject_code: RejectCode::CanisterReject,
376424
message: "Insufficient cycles".to_string(),
377425
})?;
378426

@@ -514,7 +562,7 @@ async fn transform_adapter_response(
514562
);
515563
(
516564
Err(CanisterHttpReject {
517-
reject_code: RejectCode::SysFatal,
565+
reject_code: RejectCode::CanisterReject,
518566
message: "Insufficient cycles".to_string(),
519567
}),
520568
instructions_used,
@@ -553,6 +601,7 @@ where
553601
#[cfg(test)]
554602
mod tests {
555603
use super::*;
604+
use ic_https_outcalls_pricing::CanisterCyclesCostSchedule;
556605
use ic_https_outcalls_service::{
557606
HttpsOutcallRequest, HttpsOutcallResponse, HttpsOutcallResult,
558607
https_outcalls_service_server::{HttpsOutcallsService, HttpsOutcallsServiceServer},
@@ -561,7 +610,7 @@ mod tests {
561610
use ic_logger::replica_logger::no_op_logger;
562611
use ic_test_utilities_types::messages::RequestBuilder;
563612
use ic_types::{
564-
RegistryVersion,
613+
NumberOfNodes, RegistryVersion,
565614
canister_http::{
566615
MAX_CANISTER_HTTP_RESPONSE_BYTES, PricingVersion, RefundStatus, Replication, Transform,
567616
},
@@ -660,6 +709,8 @@ mod tests {
660709
registry_version: RegistryVersion::from(1),
661710
},
662711
socks_proxy_addrs: vec![],
712+
cost_schedule: CanisterCyclesCostSchedule::Normal,
713+
subnet_size: NumberOfNodes::from(13),
663714
}
664715
}
665716

@@ -1128,6 +1179,109 @@ mod tests {
11281179
assert_eq!(client.try_receive(), Err(TryReceiveError::Empty));
11291180
}
11301181

1182+
// Test that an oversized reject message is truncated (char-boundary-safe)
1183+
// before being returned, so that what is priced and gossiped is bounded.
1184+
#[tokio::test]
1185+
async fn test_oversized_reject_message_is_truncated() {
1186+
// Adapter mock setup. Not relevant; the transform produces the reject.
1187+
let response = HttpsOutcallResponse {
1188+
status: 200,
1189+
headers: Vec::new(),
1190+
content: Vec::new(),
1191+
};
1192+
let mock_grpc_channel = setup_adapter_mock(Ok(create_result_from_response(response))).await;
1193+
let (svc, mut handle) = setup_system_query_mock();
1194+
1195+
// 300 four-byte emoji (1200 bytes) followed by a single one-byte 'x'
1196+
// (1201 bytes total). `ellipsize` keeps a prefix + "..." + suffix; the
1197+
// trailing byte makes the total length not a multiple of 4 so that the
1198+
// both suffix and prefix cut, fall *inside* an emoji.
1199+
const PREFIX_PERCENTAGE: usize = 90;
1200+
let oversized_message = format!("{}x", "😀".repeat(300));
1201+
let oversized_len = oversized_message.len();
1202+
assert!(oversized_len > MAXIMUM_CANISTER_HTTP_ERROR_MESSAGE_BYTES);
1203+
1204+
// The exact byte offsets `ellipsize` cuts at.
1205+
let budget = MAXIMUM_CANISTER_HTTP_ERROR_MESSAGE_BYTES - "...".len();
1206+
let prefix_cut = MAXIMUM_CANISTER_HTTP_ERROR_MESSAGE_BYTES * PREFIX_PERCENTAGE / 100;
1207+
let suffix_cut = oversized_len - (budget - prefix_cut);
1208+
assert!(
1209+
!oversized_message.is_char_boundary(prefix_cut),
1210+
"prefix cut at byte {prefix_cut} must fall inside a multi-byte emoji"
1211+
);
1212+
assert!(
1213+
!oversized_message.is_char_boundary(suffix_cut),
1214+
"suffix cut at byte {suffix_cut} must fall inside a multi-byte emoji"
1215+
);
1216+
1217+
// The client must apply exactly this truncation before pricing the response.
1218+
let expected_message = oversized_message
1219+
.ellipsize(MAXIMUM_CANISTER_HTTP_ERROR_MESSAGE_BYTES, PREFIX_PERCENTAGE);
1220+
tokio::spawn(async move {
1221+
let (_, rsp) = handle.next_request().await.unwrap();
1222+
rsp.send_response(Ok((
1223+
Ok(WasmResult::Reject(oversized_message)),
1224+
current_time(),
1225+
)));
1226+
});
1227+
1228+
let mut client = CanisterHttpAdapterClientImpl::new(
1229+
tokio::runtime::Handle::current(),
1230+
mock_grpc_channel,
1231+
svc,
1232+
100,
1233+
MetricsRegistry::default(),
1234+
no_op_logger(),
1235+
);
1236+
1237+
assert_eq!(
1238+
client.send(build_mock_canister_http_request(
1239+
420,
1240+
Some("transform".to_string())
1241+
)),
1242+
Ok(())
1243+
);
1244+
loop {
1245+
match client.try_receive() {
1246+
Err(_) => tokio::time::sleep(Duration::from_millis(10)).await,
1247+
Ok((r, _payment_receipt)) => {
1248+
let CanisterHttpResponseContent::Reject(reject) = r.content else {
1249+
panic!("expected a reject response");
1250+
};
1251+
// Ellipsized exactly as the limit dictates: this pins the size
1252+
// and the ellipsize parameters.
1253+
assert_eq!(
1254+
reject.message, expected_message,
1255+
"reject message should be ellipsized to the allowed size"
1256+
);
1257+
assert!(reject.message.len() <= MAXIMUM_CANISTER_HTTP_ERROR_MESSAGE_BYTES);
1258+
assert!(reject.message.len() < oversized_len);
1259+
1260+
// Although both cuts fell inside an emoji (asserted above), the
1261+
// returned message is well-formed: no emoji was split. Every
1262+
// character retained on either side of the ellipsis is a whole
1263+
// emoji (plus the preserved trailing 'x' at the very end).
1264+
let (head, tail) = reject
1265+
.message
1266+
.split_once("...")
1267+
.expect("ellipsized message must contain the ellipsis");
1268+
assert!(
1269+
!head.is_empty() && head.chars().all(|c| c == '😀'),
1270+
"prefix must consist of whole emoji, got {head:?}"
1271+
);
1272+
assert!(
1273+
tail.strip_suffix('x')
1274+
.expect("trailing byte should be preserved")
1275+
.chars()
1276+
.all(|c| c == '😀'),
1277+
"suffix must be whole emoji followed by the trailing byte, got {tail:?}"
1278+
);
1279+
break;
1280+
}
1281+
}
1282+
}
1283+
}
1284+
11311285
// Test client capacity. The capicity of the client is specified by the channel size.
11321286
#[tokio::test]
11331287
async fn test_client_at_capacity() {

rs/https_outcalls/client/src/metrics.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use prometheus::{Histogram, HistogramVec, IntCounterVec};
55
const LABEL_STATUS_CODE: &str = "status_code";
66
const LABEL_HTTP_METHOD: &str = "http_method";
77
const LABEL_STATUS: &str = "status";
8+
const LABEL_REPLICATION: &str = "replication";
89

910
#[derive(Clone)]
1011
pub struct Metrics {
@@ -35,7 +36,7 @@ impl Metrics {
3536
request_total: metrics_registry.int_counter_vec(
3637
"canister_http_requests_total",
3738
"Canister http request results returned to consensus.",
38-
&[LABEL_STATUS, LABEL_HTTP_METHOD],
39+
&[LABEL_STATUS, LABEL_HTTP_METHOD, LABEL_REPLICATION],
3940
),
4041
}
4142
}

rs/https_outcalls/consensus/BUILD.bazel

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ rust_library(
3232
"//rs/types/cycles",
3333
"//rs/types/management_canister_types",
3434
"//rs/types/types",
35-
"//rs/utils",
3635
"@crate_index//:candid",
3736
"@crate_index//:hex",
3837
"@crate_index//:prometheus",
@@ -80,7 +79,6 @@ rust_test(
8079
"//rs/types/cycles",
8180
"//rs/types/management_canister_types",
8281
"//rs/types/types",
83-
"//rs/utils",
8482
"@crate_index//:assert_matches",
8583
"@crate_index//:candid",
8684
"@crate_index//:hex",

rs/https_outcalls/consensus/Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ ic-registry-subnet-type = { path = "../../registry/subnet_type" }
2424
ic-replicated-state = { path = "../../replicated_state" }
2525
ic-types = { path = "../../types/types" }
2626
ic-types-cycles = { path = "../../types/cycles" }
27-
ic-utils = { path = "../../utils" }
2827
prometheus = { workspace = true }
2928
rand = { workspace = true }
3029
slog = { workspace = true }

0 commit comments

Comments
 (0)