Skip to content

Commit c2cebaf

Browse files
mraszykclaude
andauthored
refactor: Hold the whole consumed-cycles total in SubnetMetrics (#11335)
`SubnetMetrics::consumed_cycles_by_canisters` holds only the canisters' part of the total; every consumer adds the subnet-level aggregate on top, recomputing that part each time it reads. That is fine for a consumer that only ever looks at a committed state -- the certified tree and the gauge -- but it makes the combined value ill-defined mid-round: the canisters' part is as of the last commit, while the subnet-level accumulators move as the round proceeds. A `delete_canister` adds the deleted canister's consumption to `consumed_cycles_by_deleted_canisters` at once, so between that and the next commit the two parts count the same canister twice. Hold the whole total instead. `consumed_cycles_by_canisters` becomes `consumed_cycles_total_including_canisters`, still transient, now covering the subnet-level aggregate as well. The field is private, with a getter of the same name and a single writer, `SubnetMetrics::refresh_consumed_cycles`, which takes the canisters' part and adds `consumed_cycles_total()` on top: callers hand over that part only, so the sum lives in one place and none of them can get it wrong. The certified state tree at `/subnet/<subnet_id>/metrics` (from certification version `V29`) and the `replicated_state_consumed_cycles_since_replica_started` gauge both read the getter, so they cannot drift and any future consumer gets a self-consistent value whenever it reads. `ReplicatedState::refresh_consumed_cycles`, renamed from `refresh_consumed_cycles_by_canisters` now that it publishes more than the canisters' part, hands the canister states' sum over on every `commit_and_certify`, and `new_from_checkpoint` does the same on load, so a replica restarting from a checkpoint agrees with one that keeps running -- which `consumed_cycles_total_is_the_same_across_a_restart` pins, comparing a live state against one reloaded from the same canisters and subnet metrics. The certified encoding and the state hash are unchanged: the byte-exact expectations in `encoding/tests/compatibility.rs` and the `V29` hash in `state_manager/src/tree_hash.rs` still hold, with their fixtures now handing their canisters' part to `refresh_consumed_cycles` rather than writing the aggregate themselves. `test_traverse_subnet_metrics_includes_canister_consumed_cycles_at_v29` now also consumes subnet-level cycles, so the total it pins covers both parts rather than only the canisters' one. The gauge does now depend on the refresh having run, which in production it has, as `commit_and_certify` enqueues the observation after it; the scheduler metrics tests, which never commit a state, refresh explicitly through the new `observe_state_metrics` helper. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2d67f33 commit c2cebaf

12 files changed

Lines changed: 192 additions & 118 deletions

File tree

rs/canonical_state/src/encoding/tests/compatibility.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -307,8 +307,9 @@ fn canonical_encoding_stream_header_v26() {
307307
/// Starting with `V29`, the `Instructions` (80B) and
308308
/// `RequestAndResponseTransmission` (20B) use case entries are no longer added
309309
/// on top of the deleted canisters scalar (fixing the double counting), while
310-
/// the cycles consumed by non-deleted canisters (50B, passed to
311-
/// `encode_subnet_metrics`) are added instead. Hence the expected value becomes
310+
/// the cycles consumed by non-deleted canisters (50B, folded into the stored
311+
/// aggregate by `SubnetMetrics::refresh_consumed_cycles`) are added instead.
312+
/// Hence the expected value becomes
312313
/// 0 (deleted) + 50B (HTTP) + 100B (ECDSA) + 50B (canisters) = 200B
313314
/// (`1B 0000002E90EDD000`).
314315
///
@@ -342,8 +343,7 @@ fn canonical_encoding_subnet_metrics() {
342343
metrics.threshold_signature_agreements =
343344
BTreeMap::from([(schnorr_key_id, 15), (ecdsa_key_id, 16)]);
344345

345-
// The canister-consumed part of the reported total, included from `V29` on.
346-
metrics.consumed_cycles_by_canisters = NominalCycles::new(50_000_000_000);
346+
metrics.refresh_consumed_cycles(NominalCycles::new(50_000_000_000));
347347

348348
let expected = if certification_version >= CertificationVersion::V29 {
349349
"A4 00 05 01 1A 00 50 00 00 02 A2 00 1B 00 00 00 2E 90 ED D0 00 01 00 03 19 10 68"

rs/canonical_state/src/encoding/types.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -737,9 +737,10 @@ impl
737737
// `consumed_cycles_total_v28`, which double counts the cycles consumed
738738
// by deleted canisters and does not account for non-deleted canisters.
739739
//
740-
// Starting with `V29`, the reported total uses the fixed
741-
// `consumed_cycles_total` (which no longer double counts deleted
742-
// canisters) plus `SubnetMetrics::consumed_cycles_by_canisters`.
740+
// Starting with `V29`, the reported total is the stored
741+
// `SubnetMetrics::consumed_cycles_total_including_canisters`, which no
742+
// longer double counts deleted canisters and does account for the existing
743+
// ones.
743744
let consumed_cycles_total = if certification_version >= CertificationVersion::V29 {
744745
metrics.consumed_cycles_total_including_canisters()
745746
} else {

rs/canonical_state/src/lazy_tree_conversion.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1144,10 +1144,7 @@ fn subnets_as_tree<'a>(
11441144
subnet_id == &own_subnet_id,
11451145
"metrics",
11461146
// Starting with `V29`, the reported total also includes
1147-
// the cycles consumed by all non-deleted canisters, read
1148-
// from `SubnetMetrics::consumed_cycles_by_canisters`
1149-
// (refreshed by
1150-
// `ReplicatedState::refresh_consumed_cycles_by_canisters`).
1147+
// the cycles consumed by all non-deleted canisters.
11511148
blob(move || encode_subnet_metrics(metrics, certification_version)),
11521149
)
11531150
.with_tree_if(

rs/canonical_state/src/traversal.rs

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1199,7 +1199,9 @@ mod tests {
11991199
#[test]
12001200
fn test_traverse_subnet_metrics_includes_canister_consumed_cycles_at_v29() {
12011201
use crate::encoding::encode_subnet_metrics;
1202-
use ic_types_cycles::{CompoundCycles, Instructions, NominalCycles};
1202+
use ic_types_cycles::{
1203+
CompoundCycles, CyclesUseCase, Instructions, NominalCycles, NominalCyclesTesting,
1204+
};
12031205

12041206
let own_subnet_id = subnet_test_id(1);
12051207
let mut state = ReplicatedState::new(own_subnet_id, SubnetType::Application);
@@ -1227,6 +1229,13 @@ mod tests {
12271229
);
12281230
});
12291231

1232+
// Non-zero subnet-level consumption, so that the reported total covers both
1233+
// parts: the subnet-level aggregate and the canisters' part below.
1234+
let subnet_metrics = &mut state.metadata.subnet_metrics;
1235+
subnet_metrics.observe_consumed_cycles_by_deleted_canisters(NominalCycles::new(1_000));
1236+
subnet_metrics
1237+
.observe_consumed_cycles_with_use_case(CyclesUseCase::VetKd, NominalCycles::new(4));
1238+
12301239
// Add a non-deleted canister that has consumed some cycles.
12311240
let mut canister_state = new_canister_state(
12321241
canister_test_id(2),
@@ -1249,15 +1258,22 @@ mod tests {
12491258

12501259
// The tree reads the stored aggregate, which is zero until refreshed.
12511260
assert_eq!(
1252-
state.metadata.subnet_metrics.consumed_cycles_by_canisters,
1261+
state
1262+
.metadata
1263+
.subnet_metrics
1264+
.consumed_cycles_total_including_canisters(),
12531265
NominalCycles::zero()
12541266
);
12551267

1256-
// The refresh publishes the fold into `SubnetMetrics`.
1257-
state.refresh_consumed_cycles_by_canisters();
1268+
let subnet_level = state.metadata.subnet_metrics.consumed_cycles_total();
1269+
assert!(subnet_level > NominalCycles::zero());
1270+
state.refresh_consumed_cycles();
12581271
assert_eq!(
1259-
state.metadata.subnet_metrics.consumed_cycles_by_canisters,
1260-
consumed_by_canisters
1272+
state
1273+
.metadata
1274+
.subnet_metrics
1275+
.consumed_cycles_total_including_canisters(),
1276+
subnet_level + consumed_by_canisters
12611277
);
12621278

12631279
for certification_version in all_supported_versions() {
@@ -1285,7 +1301,7 @@ mod tests {
12851301

12861302
// The canister's consumed cycles are included only starting with V29.
12871303
let mut metrics_without_canisters = state.metadata.subnet_metrics.clone();
1288-
metrics_without_canisters.consumed_cycles_by_canisters = NominalCycles::zero();
1304+
metrics_without_canisters.refresh_consumed_cycles(NominalCycles::zero());
12891305
let without_canisters =
12901306
encode_subnet_metrics(&metrics_without_canisters, certification_version);
12911307
if certification_version >= CertificationVersion::V29 {

rs/execution_environment/src/scheduler/tests/metrics.rs

Lines changed: 25 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
//! Tests for scheduler metrics.
22
33
use super::super::test_utilities::{
4-
SchedulerTestBuilder, TestInstallCode, ingress, instructions, on_response, other_side,
4+
SchedulerTest, SchedulerTestBuilder, TestInstallCode, ingress, instructions, on_response,
5+
other_side,
56
};
67
use super::super::*;
78
use super::{
@@ -44,6 +45,20 @@ use ic_types_test_utils::ids::{canister_test_id, message_test_id, subnet_test_id
4445
use more_asserts::assert_ge;
4546
use std::time::Duration;
4647

48+
/// Observes the state metrics at `height`, having first refreshed the derived
49+
/// consumed-cycles total that the `replicated_state_consumed_cycles_since_replica_started`
50+
/// gauge reads. Production refreshes it on every `commit_and_certify`, which this
51+
/// harness never does.
52+
fn observe_state_metrics(test: &mut SchedulerTest, height: u64) {
53+
test.state_mut().refresh_consumed_cycles();
54+
test.state_metrics().observe(
55+
test.state().metadata.own_subnet_id,
56+
test.state(),
57+
height.into(),
58+
&no_op_logger(),
59+
);
60+
}
61+
4762
#[test]
4863
fn validate_consumed_instructions_metric() {
4964
let mut test = SchedulerTestBuilder::new()
@@ -892,12 +907,7 @@ fn threshold_signature_agreements_metric_is_updated() {
892907
])
893908
.build();
894909

895-
test.state_metrics().observe(
896-
test.state().metadata.own_subnet_id,
897-
test.state(),
898-
1.into(),
899-
&no_op_logger(),
900-
);
910+
observe_state_metrics(&mut test, 1);
901911

902912
let canister_id = test.create_canister();
903913

@@ -1056,12 +1066,7 @@ fn threshold_signature_agreements_metric_is_updated() {
10561066

10571067
test.execute_round(ExecutionRoundType::OrdinaryRound);
10581068

1059-
test.state_metrics().observe(
1060-
test.state().metadata.own_subnet_id,
1061-
test.state(),
1062-
2.into(),
1063-
&no_op_logger(),
1064-
);
1069+
observe_state_metrics(&mut test, 2);
10651070

10661071
let threshold_signature_agreements_after = &test
10671072
.state()
@@ -1116,12 +1121,7 @@ fn consumed_cycles_ecdsa_outcalls_are_added_to_consumed_cycles_total() {
11161121

11171122
let canister_id = test.create_canister();
11181123

1119-
test.state_metrics().observe(
1120-
test.state().metadata.own_subnet_id,
1121-
test.state(),
1122-
0.into(),
1123-
&no_op_logger(),
1124-
);
1124+
observe_state_metrics(&mut test, 0);
11251125

11261126
let consumed_cycles_before = NominalCycles::new(
11271127
fetch_gauge(
@@ -1153,12 +1153,7 @@ fn consumed_cycles_ecdsa_outcalls_are_added_to_consumed_cycles_total() {
11531153
.sign_with_ecdsa_contexts();
11541154
assert_eq!(sign_with_ecdsa_contexts.len(), 1);
11551155

1156-
test.state_metrics().observe(
1157-
test.state().metadata.own_subnet_id,
1158-
test.state(),
1159-
0.into(),
1160-
&no_op_logger(),
1161-
);
1156+
observe_state_metrics(&mut test, 0);
11621157
let consumed_cycles_after = NominalCycles::new(
11631158
fetch_gauge(
11641159
test.metrics_registry(),
@@ -1197,12 +1192,7 @@ fn consumed_cycles_http_outcalls_are_added_to_consumed_cycles_total() {
11971192
.subnet_features
11981193
.http_requests = true;
11991194

1200-
test.state_metrics().observe(
1201-
test.state().metadata.own_subnet_id,
1202-
test.state(),
1203-
0.into(),
1204-
&no_op_logger(),
1205-
);
1195+
observe_state_metrics(&mut test, 0);
12061196

12071197
let consumed_cycles_before = NominalCycles::new(
12081198
fetch_gauge(
@@ -1263,12 +1253,7 @@ fn consumed_cycles_http_outcalls_are_added_to_consumed_cycles_total() {
12631253
Some(NumBytes::from(response_size_limit)),
12641254
);
12651255

1266-
test.state_metrics().observe(
1267-
test.state().metadata.own_subnet_id,
1268-
test.state(),
1269-
0.into(),
1270-
&no_op_logger(),
1271-
);
1256+
observe_state_metrics(&mut test, 0);
12721257
let consumed_cycles_after = NominalCycles::new(
12731258
fetch_gauge(
12741259
test.metrics_registry(),
@@ -1434,12 +1419,7 @@ fn consumed_cycles_for_instructions_are_updated_from_valid_canisters() {
14341419
.system_state
14351420
.consume_cycles(removed_cycles);
14361421

1437-
test.state_metrics().observe(
1438-
test.state().metadata.own_subnet_id,
1439-
test.state(),
1440-
0.into(),
1441-
&no_op_logger(),
1442-
);
1422+
observe_state_metrics(&mut test, 0);
14431423

14441424
assert_eq!(
14451425
fetch_gauge_vec(
@@ -1480,12 +1460,7 @@ fn consumed_cycles_for_resource_allocations_are_updated_from_valid_canisters() {
14801460
test.advance_time(duration);
14811461
test.charge_for_resource_allocations();
14821462

1483-
test.state_metrics().observe(
1484-
test.state().metadata.own_subnet_id,
1485-
test.state(),
1486-
0.into(),
1487-
&no_op_logger(),
1488-
);
1463+
observe_state_metrics(&mut test, 0);
14891464

14901465
let expected_memory_cycles = (test.memory_cost(memory_allocation, duration)
14911466
+ test.canister_base_cost(memory_allocation, duration))
@@ -1558,12 +1533,7 @@ fn consumed_cycles_are_updated_from_deleted_canisters() {
15581533
);
15591534
test.execute_round(ExecutionRoundType::OrdinaryRound);
15601535

1561-
test.state_metrics().observe(
1562-
test.state().metadata.own_subnet_id,
1563-
test.state(),
1564-
0.into(),
1565-
&no_op_logger(),
1566-
);
1536+
observe_state_metrics(&mut test, 0);
15671537

15681538
assert_eq!(
15691539
fetch_gauge_vec(

rs/replicated_state/src/metadata_state.rs

Lines changed: 34 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -440,15 +440,10 @@ pub struct SubnetMetrics {
440440
/// Transactions here refer to all messages processed in replicated mode.
441441
pub update_transactions_total: u64,
442442

443-
/// The total cycles consumed by the canisters that currently exist on this
444-
/// subnet, i.e. the sum of `CanisterMetrics::consumed_cycles()` over all of
445-
/// them.
446-
///
447-
/// Derived, not persisted: refreshed by
448-
/// `ReplicatedState::refresh_consumed_cycles_by_canisters` when a state is
449-
/// committed, and re-derived by `ReplicatedState::new_from_checkpoint` on load.
443+
/// Backing store of [`Self::consumed_cycles_total_including_canisters()`]; zero
444+
/// until [`Self::refresh_consumed_cycles`] derives it.
450445
#[validate_eq(Ignore)]
451-
pub consumed_cycles_by_canisters: NominalCycles,
446+
consumed_cycles_total_including_canisters: NominalCycles,
452447
}
453448

454449
impl SubnetMetrics {
@@ -586,14 +581,14 @@ impl SubnetMetrics {
586581
&self.consumed_cycles_by_use_case_as_counters
587582
}
588583

589-
/// Computes the total consumed cycles on the subnet.
584+
/// Computes the subnet-level aggregate of the consumed cycles, i.e. the part
585+
/// of the total that is not held by the canisters that still exist.
590586
///
591587
/// This is the current computation, which avoids double counting the cycles
592-
/// consumed by deleted canisters. The canonical state consumer uses it
593-
/// starting with certification version `V29`, adding on top the cycles
594-
/// consumed by all non-deleted canisters; for earlier certification
595-
/// versions the consumer uses the legacy [`Self::consumed_cycles_total_v28`]
596-
/// instead.
588+
/// consumed by deleted canisters, as the legacy
589+
/// [`Self::consumed_cycles_total_v28`] does. It is one of the two summands of
590+
/// [`Self::consumed_cycles_total_including_canisters`], which is what the
591+
/// canonical state consumer reports from certification version `V29` on.
597592
pub fn consumed_cycles_total(&self) -> NominalCycles {
598593
let mut total = NominalCycles::zero();
599594

@@ -647,16 +642,30 @@ impl SubnetMetrics {
647642
total
648643
}
649644

650-
/// All cycles removed from circulation on the subnet, by both deleted and
651-
/// still-existing canisters: the subnet-level aggregate
652-
/// ([`Self::consumed_cycles_total`]) plus [`Self::consumed_cycles_by_canisters`].
645+
/// All cycles removed from circulation on this subnet, by both deleted and
646+
/// still-existing canisters: [`Self::consumed_cycles_total`] plus the sum of
647+
/// `CanisterMetrics::consumed_cycles()` over the canisters that currently
648+
/// exist, as of the end of the last committed round.
653649
///
654-
/// Both the certified state tree at `/subnet/<subnet_id>/metrics` (from
655-
/// certification version `V29`) and the
656-
/// `replicated_state_consumed_cycles_since_replica_started` gauge report this
657-
/// same definition, so the two cannot drift apart.
650+
/// Every consumer of the full total reads it here -- the certified state tree at
651+
/// `/subnet/<subnet_id>/metrics` (from certification version `V29`) and the
652+
/// `replicated_state_consumed_cycles_since_replica_started` gauge -- so they
653+
/// cannot drift apart.
658654
pub fn consumed_cycles_total_including_canisters(&self) -> NominalCycles {
659-
self.consumed_cycles_total() + self.consumed_cycles_by_canisters
655+
self.consumed_cycles_total_including_canisters
656+
}
657+
658+
/// Recomputes [`Self::consumed_cycles_total_including_canisters`] from the
659+
/// subnet-level aggregate and `consumed_by_canisters`, the sum of
660+
/// `CanisterMetrics::consumed_cycles()` over the canisters that currently exist.
661+
///
662+
/// Callers pass the canisters' part only; adding the subnet-level part happens
663+
/// here, so no caller can get it wrong. The total is derived, not
664+
/// persisted: `ReplicatedState::refresh_consumed_cycles` calls this whenever a
665+
/// state is committed and `ReplicatedState::new_from_checkpoint` on load.
666+
pub fn refresh_consumed_cycles(&mut self, consumed_by_canisters: NominalCycles) {
667+
self.consumed_cycles_total_including_canisters =
668+
self.consumed_cycles_total() + consumed_by_canisters;
660669
}
661670

662671
/// Legacy computation of the total consumed cycles, used by the canonical
@@ -667,8 +676,9 @@ impl SubnetMetrics {
667676
/// `consumed_cycles_by_deleted_canisters` and to the
668677
/// `consumed_cycles_by_use_case` map, and both are summed here. It is kept
669678
/// unchanged to preserve the certified state for certification versions up
670-
/// to and including `V28`; [`Self::consumed_cycles_total`] fixes the double
671-
/// counting starting with certification version `V29`.
679+
/// to and including `V28`; from `V29` on the consumer reports
680+
/// [`Self::consumed_cycles_total_including_canisters`], which does not
681+
/// double count.
672682
pub fn consumed_cycles_total_v28(&self) -> NominalCycles {
673683
let mut total = NominalCycles::zero();
674684

rs/replicated_state/src/metadata_state/proto.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ impl TryFrom<pb_metadata::SubnetMetrics> for SubnetMetrics {
353353
// Transient, with no corresponding proto field:
354354
// `ReplicatedState::new_from_checkpoint` derives it from the canisters
355355
// it loads.
356-
consumed_cycles_by_canisters: NominalCycles::zero(),
356+
consumed_cycles_total_including_canisters: NominalCycles::zero(),
357357
num_canisters: try_from_option_field(
358358
item.num_canisters,
359359
"SubnetMetrics::num_canisters",

0 commit comments

Comments
 (0)