feat: Add subnet_metrics management canister endpoint - #11032
Open
Dfinity-Bjoern wants to merge 4 commits into
Open
feat: Add subnet_metrics management canister endpoint#11032Dfinity-Bjoern wants to merge 4 commits into
Dfinity-Bjoern wants to merge 4 commits into
Conversation
Implements the EXPERIMENTAL `subnet_metrics` endpoint from dfinity/developer-docs#333: given a subnet ID, returns that subnet's current block height, canister count, total canister state size, total consumed cycles, and total processed transactions. Canister-callable only; not reachable via ingress. Four of the five values were previously readable only by external users via the certified state tree at /subnet/<subnet_id>/metrics, which canisters cannot read. `block_height` is new: it is `current_round`, already deterministic at execution time (the same value `vetkd_derive_key` already commits to replicated state). `subnet_id` may name any subnet. Routing delivers the call to the named subnet, so the `args.subnet_id == own_subnet_id` check in the handler mirrors `node_metrics_history` and does not block cross-subnet calls; it guards the NNS direct-subnet-addressing path, where a call can reach subnet A while naming subnet B. Notes for future readers, since these are easy to "fix" back: * The instruction charge is keyed on `hot_len()`, NOT `num_canisters()`. The fold in `total_consumed_cycles()` visits hot canisters only, and `hot_len() << len()` is the steady state. Keying on the total over-charges ~41x at 100k canisters, which does not protect the subnet — it lets ~61 calls/round pin the whole shared subnet-message budget and defer install_code/snapshot traffic. Priced against the already enabled `fetch_canister_logs` (2.4 cycles per round-instruction of budget), hot-keyed `subnet_metrics` costs an attacker 3.6. `subnet_metrics_charge_ignores_cold_canisters` fails if this regresses. * `hot_len()` is the first partition-cardinality input to execution, so the unconditional `repartition_canister_states()` call in `commit_and_certify` is now a correctness requirement, not an optimisation. Moving it inside the `CertificationScope::Metadata` branch would diverge the charge across replicas. `hot_cold_partition_is_canonical_after_every_commit` guards this. * `canister_state_bytes` is read from the stored `subnet_metrics` field and must not be recomputed live: the stored value refreshes only every 10 rounds by design, so recomputing would disagree with the certified state tree on 9 rounds out of 10. * `validate_cold_stats()` alerts; it does not enforce. `validate_eq_checkpoint` discards the error and the checkpoint still finalizes. Describe it as detection, not prevention. * The system tests in general_execution_tests/api_tests.rs are Linux-only and could not be compiled locally. CI is their first real check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Implements the experimental subnet_metrics management canister endpoint across the IC execution stack (routing, permissions, metering, and state access), plus adds unit/system tests and benchmarks to pin determinism and cost-model behavior.
Changes:
- Adds
SubnetMetricsas a new management canister method with canister-callable-only enforcement and composite-query rejection. - Implements execution logic returning subnet metrics (including
block_height) with round-instruction charging keyed tohot_len(). - Adds broad regression coverage (execution env, state manager/checkpoint validation, routing tests, candid fixtures, and criterion benches).
Reviewed changes
Copilot reviewed 33 out of 33 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| rs/types/types/src/messages/inter_canister.rs | Treats SubnetMetrics as having no effective canister id for canister calls. |
| rs/types/types/src/messages/ingress_messages.rs | Ensures SubnetMetrics is treated as a subnet method (not allowed for ingress). |
| rs/types/management_canister_types/tests/ic.did | Adds Candid fixture types + service method for subnet_metrics. |
| rs/types/management_canister_types/tests/candid_equality.rs | Extends candid-equality coverage with subnet_metrics stub. |
| rs/types/management_canister_types/src/lib.rs | Adds Method::SubnetMetrics and public arg/response Rust types. |
| rs/tests/execution/general_execution_tests/api_tests.rs | Adds end-to-end system tests for own-subnet, cross-subnet, and query/composite-query failure modes. |
| rs/tests/execution/general_execution_test.rs | Registers the new subnet_metrics system tests in the execution test group. |
| rs/test_utilities/execution_environment/src/lib.rs | Exposes and plumbs current_round in the test harness; updates round-instruction special-casing. |
| rs/state_manager/tests/state_manager.rs | Adds a regression test pinning unconditional repartitioning for deterministic hot_len()-keyed charging. |
| rs/state_manager/src/lib.rs | Documents why repartition_canister_states() must remain unconditional. |
| rs/state_manager/src/checkpoint.rs | Adds advisory cold_stats aggregate validation during checkpoint validation and preserves per-canister diagnostics. |
| rs/replicated_state/src/replicated_state.rs | Strengthens documentation for repartitioning as a correctness requirement (not just an optimization). |
| rs/replicated_state/src/canister_states/tests.rs | Adds tests for total_consumed_cycles correctness and validate_cold_stats behavior. |
| rs/replicated_state/src/canister_states.rs | Adds validate_cold_stats() API and documents its advisory validation role. |
| rs/execution_environment/tests/execution_test.rs | Adds instruction-accounting tests for subnet_metrics (budget respect + hot-vs-total scaling + block height progression). |
| rs/execution_environment/src/scheduler.rs | Special-cases SubnetMetrics like ListCanisters for round-limit gating and adds instruction-limit selection entry. |
| rs/execution_environment/src/ic00_permissions.rs | Documents/records that SubnetMetrics bypasses effective-canister-id permission path. |
| rs/execution_environment/src/execution_environment.rs | Implements SubnetMetrics handler, response construction, and hot-keyed round-instruction charge function. |
| rs/execution_environment/src/execution_environment_metrics.rs | Extends metrics labeling to include SubnetMetrics. |
| rs/execution_environment/src/canister_manager/tests.rs | Adds focused unit tests for canister-only access, block height, foreign-subnet rejection, payload decoding, etc. |
| rs/execution_environment/src/canister_manager.rs | Adds SubnetMetrics to ingress-filter rejection arm for defense in depth. |
| rs/execution_environment/benches/management_canister/test_canister/src/main.rs | Adds a test-canister helper method to call subnet_metrics for benchmarks. |
| rs/execution_environment/benches/management_canister/test_canister/candid.did | Updates the benchmark test canister Candid interface with subnet_metrics. |
| rs/execution_environment/benches/management_canister/subnet_metrics.rs | New benchmarks measuring end-to-end and hot-fold costs for subnet_metrics. |
| rs/execution_environment/benches/management_canister/main.rs | Registers the new subnet_metrics benchmark module. |
| rs/embedders/src/wasmtime_embedder/system_api/sandbox_safe_system_state.rs | Allows SubnetMetrics through the system-state-modifications method allowlist. |
| rs/embedders/src/wasmtime_embedder/system_api/routing.rs | Adds routing for SubnetMetrics (payload subnet) and explicit composite-query rejection + tests. |
| rs/canonical_state/src/encoding/tests/subnet_metrics.rs | New cross-check ensuring consumed_cycles_total matches canonical encoding at V29. |
| rs/canonical_state/src/encoding.rs | Registers the new canonical-state subnet-metrics test module. |
| packages/ic-management-canister-types/tests/ic.did | Updates public-package Candid fixture with subnet_metrics types + service method. |
| packages/ic-management-canister-types/tests/candid_equality.rs | Adds candid-equality stub method for subnet_metrics. |
| packages/ic-management-canister-types/src/lib.rs | Adds exported SubnetMetricsArgs / SubnetMetricsResult types (public bindings). |
| packages/ic-management-canister-types/CHANGELOG.md | Notes addition of subnet_metrics types in the package changelog. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…reshness Addresses the CI failure and the Copilot review. No production logic changed; this is test code and doc comments only. **Composite-query system test.** `subnet_metrics_composite_query_fails` asserted that the routing rejection's message reaches the caller. It does not: `reject_subnet_message_routing`'s synthesized response is never delivered on the query path, so the universal canister never replies and the outer query fails `CanisterError` / "did not produce a response". This is established platform behaviour of the composite-query arm in `resolve_destination`, not something this change introduced. A control experiment showed `fetch_canister_logs` — which has the identical arm and ships enabled — behaves identically, while `canister_status`, which has no such arm, does deliver its reject (no arm means the request is created and `QueryContext::handle_request`'s reject is delivered normally). The test now asserts the real behaviour and says plainly that this makes it weak: it cannot distinguish the arm from any other failure to reply, and would pass against a stub. The method-specific assertion lives in `resolve_subnet_metrics_rejects_composite_query` in `routing.rs`, which tests `resolve_destination` directly. The division of labour is: the unit test proves the arm, the system test documents user-visible behaviour. The now-inert `.on_reject(...)` is kept deliberately, so that if the platform ever does deliver the reject, the test fails loudly rather than quietly continuing to assert the swallowed behaviour. All five `subnet_metrics` system tests now pass, verified by execution on a Linux host rather than by inspection — including the cross-subnet attribution test, which is the first genuine cross-subnet management-call test in the repo. **Field freshness docs.** Per review, the Rust doc comments described values as "current" when four of the five lag: only `block_height` is current, the other four are as of end-of-previous-round, and `canister_state_bytes` is refreshed only every 10 rounds (so it reads 0 early in a subnet's life). Documented on both `SubnetMetricsResult` and `SubnetMetricsResponse`. The review also asked for the same wording change in the two `ic.did` fixtures. Deliberately not done: those must stay byte-identical to the upstream spec's `public/references/ic.did`. That wording fix belongs in dfinity/developer-docs#333, which already carries an open item on imprecise gauge-vs-counter wording. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three simplifications, no behaviour change. Net -296 insertions, -4 files. **Share the consumed-cycles formula instead of testing for drift.** `SubnetMetrics::consumed_cycles_total_including_canisters()` is now called by both the canonical-state encoder and the `subnet_metrics` handler, so the invariant is structural rather than pinned by a cross-check test. That test (78 lines) and its reciprocal keep-in-sync comments are deleted. The method lives on `SubnetMetrics` rather than `ReplicatedState` because `SubnetMetrics::from` — where the state tree does the addition — has no `ReplicatedState` in scope. Only the `>= V29` branch is rerouted through the new method; the `<= V28` branch still calls `consumed_cycles_total_v28()` untouched, so no state hash at any existing certification version moves. **Drop the `end_to_end` benchmark group.** It was never successfully measured, and the per-canister constant came from `bench_consumed_cycles_fold` instead. Its test-canister plumbing goes with it, restoring `benches/management_canister/test_canister/` to its previous state. The base-cost doc comment now states plainly that the base is estimated from the handler's fixed work and was never measured end to end, rather than pointing at a benchmark that no longer exists. **Move `validate_cold_stats()` out to its own change.** It is hardening for pre-existing code, not a requirement of this endpoint: `ColdStats` is already consensus-critical today via `canister_state_bytes`, with no check at all. The determinism argument for reading `hot_len` does not depend on it — it rests on `is_cold()` being time-independent, the partition never being serialized, unconditional repartitioning at commit, and all four state acquisition paths agreeing. `rs/state_manager/src/checkpoint.rs` and `rs/replicated_state/src/canister_states.rs` are byte-identical to master again. What deliberately stays, because it guards a coupling *this* change introduces rather than the removed check: `hot_cold_partition_is_canonical_after_every_commit`, the `repartition_canister_states` doc comment, and the test that `total_consumed_cycles()` equals a direct fold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
✅ No security or compliance issues detected. Reviewed everything up to 0e795b7. Security Overview
Detected Code Changes
|
Per review feedback. Behaviour-neutral: `counts_toward_round_limit` is read only in `Ic00MethodPermissions::can_be_executed`, whose single call site in the repo is `scheduler.rs:1847` — and `can_execute_subnet_msg` returns earlier, at the `ListCanisters | SubnetMetrics` special case, so the flag is unreachable for this method. Both affected test targets pass unchanged. The flag now records that the method does consume round instructions. Two comments had to change to keep the tree self-consistent: * The note in `ic00_permissions.rs` no longer says the flag is unset because it is not consulted. It states that the flag is not consulted, and warns that the deferral comes from the dedicated special case in `can_execute_subnet_msg`, which must not be removed on the strength of this flag. * The doc on `check_consumes_round_instructions_without_effective_canister_id` said such methods "cannot use `Ic00MethodPermissions::counts_toward_round_limit`", which is no longer accurate for `subnet_metrics`. It now says the flag is never consulted for them and so cannot identify them whatever its value — true for both entries. `ListCanisters` is in the identical position and remains `false`. Aligning it would be more consistent but changes pre-existing configuration outside this change's scope; the asymmetry is noted in the comment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the EXPERIMENTAL
subnet_metricsmanagement canister endpoint specified in dfinity/developer-docs#333.Canister-callable only; not reachable via ingress.
subnet_idmay name any subnet — routing delivers the call to the named subnet, so theargs.subnet_id == own_subnet_idcheck mirrorsnode_metrics_historyand does not block cross-subnet calls. It guards the NNS direct-subnet-addressing path, where a call can reach subnet A while naming subnet B and would otherwise be answered with subnet A's metrics.Four of the five values were previously readable only by external users via the certified state tree at
/subnet/<subnet_id>/metrics, which canisters cannot read.block_heightis genuinely new: it iscurrent_round, already deterministic at execution time — the same valuevetkd_derive_keycommits to replicated state today.No replicated-state, protobuf, or certification-version change. No
Cargo.tomlor Bazel change.Design points worth reviewer attention
The instruction charge is keyed on
hot_len(), notnum_canisters().total_consumed_cycles()folds over hot canisters only, andhot_len() << len()is the steady state, so keying on the total over-charges ~41× at 100k canisters. That does not protect the subnet — it lets ~61 calls/round pin the entire shared 250M subnet-message budget and deferinstall_code/upload_chunk/ snapshot traffic, for roughly $3.5/day, from a canister with no foothold on the target subnet. Priced against the already-enabledfetch_canister_logs(2.4 cycles per round-instruction of budget consumed), the hot-keyed endpoint costs an attacker 3.6.subnet_metrics_charge_ignores_cold_canistersfails if this regresses.hot_len()is the first partition-cardinality input to execution. That makes the unconditionalrepartition_canister_states()call incommit_and_certifya correctness requirement rather than an optimisation — moving it inside theCertificationScope::Metadatabranch would diverge the charge across replicas. Determinism rests on four legs:is_cold()is time-independent; the partition is never serialized (every load path derives it viaCanisterStates::new); repartitioning is unconditional at commit, so committed equals derived at every height; and all four state-acquisition paths therefore agree.hot_cold_partition_is_canonical_after_every_commitguards the coupling.consumed_cycles_totaluses the corrected V29 computation.CURRENT_CERTIFICATION_VERSIONis V27, so the certified state tree still reports the legacy double-counting value and the two will disagree until V29 becomes current. This was a deliberate choice: report the correct number from day one rather than bake a known bug into a new interface.canister_state_bytesis read from the stored field, never recomputed. The stored value refreshes only every 10 rounds by design, so a live recomputation would disagree with the state tree on 9 rounds out of 10.validate_cold_stats()alerts; it does not enforce.validate_eq_checkpointdiscards the error and the checkpoint still finalizes, with detection lagging a checkpoint interval. It is a strict improvement —ColdStatsis already consensus-critical today viacold_stats.memory_usage→canister_state_bytes, with no check at all — but it should be described as detection, not prevention.Verification
cargo check --all-targetsclean;cargo fmt -- --checkexit 0; targeted clippy with--deny warningsclean apart from three pre-existingunused_imports. Roughly 80 of the 464 depth-2 Bazel targets run, including bothcandid_equalitytargets and allsubnet_metricsunit tests. Candid fixtures verified byte-identical to the spec in both crates.Not verified locally, and needing CI:
rs/tests/execution/general_execution_tests/api_tests.rsare Linux-only and have never been compiled. This includes the cross-subnet test — the spec's headline capability — and the composite-query rejection test. Symbols were hand-checked; CI is their first real check../ci/scripts/rust-lint.shcould not complete: its clippy step is--all-features --workspace, which hits a pre-existing break atrs/types/management_canister_types/src/lib.rs:2431unrelated to this change.//rs/state_manager:state_manager_integration/lsmt_merge_overheadfails, reproduced identically on a clean tree.end_to_endbenchmark group was not measured, so the 100K base constant is estimated from the handler's fixed work rather than measured. It errs generous. A suggested pass line for a future run is ≤ 50 µs/call.Follow-ups, out of scope here
ic-cdkand Motoko bindings; optionally addingblock_heightto the state tree so external users get it in certified form; consensus sign-off on introducing a block-height concept to the interface spec.🤖 Generated with Claude Code