feat: Backfill the canister-level monotonic consumed cycles - #11490
Conversation
`CanisterMetrics::consumed_cycles_monotonic` is absent in checkpoints written
before it was introduced, and decodes as zero. Left alone it would only start
accruing from the round it was added in, so every canister that predates it
under-reports its lifetime consumption forever.
The `consumed_cycles` gauge predates it and holds the full history, so the
monotonic value can be derived from it. `SystemState::outstanding_prepayments()`
sums the prepayments that have been added to the gauge but whose refund has not
been observed yet -- the ones held in an open `Callback` or in the
`prepaid_execution_cycles` of an aborted execution or `install_code`. All of
them are recorded in the replicated state, which yields the invariant
consumed_cycles - outstanding_prepayments() == consumed_cycles_monotonic
and makes `SystemState::migrate_consumed_cycles_to_monotonic()` exact. Because
the invariant holds at all times rather than just once, the backfill is also
idempotent: it is a no-op for a canister already backfilled, and self-healing if
a downgrade dropped the field.
The scheduler runs it on checkpoint rounds only, after
`abort_all_paused_executions`: a paused execution holds its prepayment in
memory, not in the state, so `outstanding_prepayments()` returns `None` for one
and the backfill would otherwise overestimate. Aborting materializes those
prepayments into the task queues. The pass takes a mutable reference to a
canister only when it has something to write, so it stays read-only once the
subnet has been backfilled.
The two states this considers unreachable -- a canister whose outstanding
prepayments cannot be derived, and a monotonic value already above the gauge net
of them -- are reported through `debug_assert_or_critical_error!` and a new
`scheduler_consumed_cycles_invariant_broken` error counter, since both would
otherwise be silent in production.
No metric or behaviour changes: nothing outside of tests reads
`consumed_cycles_monotonic` yet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Saturating subtraction can silently hide an outstanding-prepayments invariant violation.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Backfills canister-level monotonic consumed cycles from existing accounting state during checkpoint rounds.
Changes:
- Derives outstanding prepayments from callbacks and aborted executions.
- Runs idempotent checkpoint migration with invariant-error metrics.
- Adds unit and scheduler coverage for migration scenarios.
File summaries
| File | Description |
|---|---|
rs/state_layout/src/state_layout/proto.rs |
Documents legacy checkpoint decoding. |
rs/replicated_state/src/canister_state/tests.rs |
Tests prepayment derivation and migration. |
rs/replicated_state/src/canister_state/system_state.rs |
Implements derivation and backfill. |
rs/execution_environment/src/scheduler/tests/metrics.rs |
Tests checkpoint integration. |
rs/execution_environment/src/scheduler/scheduler_metrics.rs |
Adds invariant error counter. |
rs/execution_environment/src/scheduler.rs |
Executes and validates migration. |
rs/execution_environment/src/canister_manager.rs |
Documents deletion accounting invariant. |
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 1
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Address Copilot review on #11490. `NominalCycles` subtraction saturates at zero, so the backfill turned a gauge below the outstanding prepayments into a `derived` of zero. For a canister whose monotonic value is zero too -- which is every canister yet to be backfilled -- that compared `Equal` and was silently accepted, even though it is exactly the corrupt accounting state the new error counter exists to surface. Check `outstanding > consumed_cycles()` before subtracting and report it through the same `debug_assert_or_critical_error!` path, leaving the canister alone. The regression test asserts the panic that the debug assertion raises, and is deliberately free of any other assertion after the round, so that it fails rather than passing vacuously if the check is dropped. `reset_consumed_cycles()` joins the existing `reset_consumed_cycles_monotonic()` testing helper to construct the broken state. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
The migration depends on accounting invariants spanning callbacks, execution lifecycle states, checkpointing, and subnet operations.
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Balanced
The same saturating subtraction that Copilot flagged in the backfill was in the test helper that asserts the invariant: `consumed_cycles() - outstanding` bottoms out at zero, so a gauge that had fallen behind the outstanding prepayments would have compared equal to a zero monotonic value and slipped through the very assertion named after the invariant. Assert `outstanding <= consumed_cycles()` on its own, ahead of the subtraction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
The deletion-accounting documentation incorrectly assumes the monotonic value has already been backfilled.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 1
- Review effort level: Balanced
Address Copilot review on #11490. The note on the deletion path claimed that a canister's `consumed_cycles` gauge and its `consumed_cycles_monotonic` are equal there, justified by a deleted canister holding no outstanding prepayment. That justification only covers a canister that has already been backfilled. Deletion is a management call, so it runs in an ordinary round, while the backfill runs on checkpoint rounds only: a canister decoded from a checkpoint predating the field can be deleted with a zero monotonic value and a nonzero gauge. Moving the gauge is still right -- now for the stated reason that only the gauge is guaranteed to hold the full history. The same gap was in the invariant documented on `outstanding_prepayments`, which `migrate_consumed_cycles_to_monotonic` was said to rely on. It does not hold before the backfill; the backfill is what establishes it, and the left-hand side is exactly the value it has to write. Documentation only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
A critical invariant-error branch lacks direct regression coverage.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 1
- Review effort level: Balanced
Address Copilot review on #11490. The `Ordering::Less` arm of the backfill had no regression coverage: the existing corruption test resets both metrics, so it returns through the earlier `outstanding > consumed_cycles` check and never reaches it. Reaching that arm needs the outstanding prepayments to be zero -- with one still outstanding, dropping the gauge trips the earlier check first -- so the new test runs an ingress execution to completion, which refunds its prepayment, and then drops only the gauge. Verified in both directions: the test fails if the `Ordering::Less` report is removed, and the pre-existing corruption test keeps passing, so the two cover distinct arms. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
The replicated cycles-accounting migration depends on a subtle system-wide invariant and warrants final human validation.
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Drop the notes on the canister deletion and checkpoint decoding paths, and the paragraphs on `SystemState::outstanding_prepayments` about paused executions and about an `install_code` dropped by a subnet split. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A paused response execution is paid for by the callback that the task carries, so its prepayment could be derived from the replicated state. But the only caller runs after all paused executions have been aborted, so treating every paused execution alike keeps the contract simple. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The scheduler derives the outstanding prepayments itself before calling `SystemState::migrate_consumed_cycles_to_monotonic`, so it has no use for the flag; only the unit tests looked at it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The non-zero monotonic consumed cycles only held because of the ingress executions preceding the xnet call, not because of the invariant under test. And `checkpoint_round_reports_monotonic_above_the_gauge` was inserted above the test it calls itself the mirror image of. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
Two unconstrained panic tests can pass without exercising their intended invariant branches.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
rs/execution_environment/src/scheduler/tests/metrics.rs:1212
- This unconstrained
should_paniccan pass on any setup assertion failure before the checkpoint round (for example, ifoutstandingunexpectedly becomes zero), without exercising the invariant-error branch. Constrain the expected panic fromdebug_assert!(false)so setup regressions fail the test instead of producing a false positive.
This issue also appears on line 1238 of the same file.
rs/execution_environment/src/scheduler/tests/metrics.rs:1238
- This unconstrained
should_panicalso treats failures in the ingress/setup assertions as success, so the test can pass without reaching theOrdering::Lessreport. Constrain it to the panic emitted by the targetdebug_assert!(false)(or catch the unwind only around the checkpoint call).
#[should_panic]
- Files reviewed: 5/5 changed files
- Comments generated: 0 new
- Review effort level: Balanced
The unit test fabricated the state it then measured: it unregistered the callback and enqueued the aborted task itself, so it could not tell whether production ever reaches that state. Drive a paused response callback through a checkpoint round instead, and assert what the abort leaves behind: an `AbortedExecution` whose input is the response, which prepays nothing of its own, and whose callback is no longer registered, so its prepayments are what is outstanding and are left out of the backfilled monotonic amount. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`prepayment_for_call_transmission` is zero for callbacks created before April 2026, so both `SystemState::outstanding_prepayments()` and the refund path in `ResponseHelper::apply_initial_refunds()` fall back on `prepayment_for_response_transmission` -- independently of each other, in two different crates. The unit test could only check the former against a hand-written expectation, so nothing tied the two together. Execute the response of a legacy callback instead and assert that the prepayments reported as outstanding are the ones the refund path settles. Zeroing the prepayment of a callback needs a new testing hook, as no production code writes such a callback any more. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Settling a legacy callback leaves the monotonic amount lagging the gauge by the call fee, which the fallback on `prepayment_for_response_transmission` cannot account for. The gauge is the amount really consumed, so the next backfill closes that gap; assert it, and note on the backfill that this is why it has to stay in place while such callbacks may still be open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
It changes consensus-critical cycle accounting and checkpoint migration across several execution states, warranting final human review.
Review details
- Files reviewed: 8/8 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Settling a legacy callback was said to leave the monotonic amount lagging the gauge by the call fee that the fallback on `prepayment_for_response_transmission` cannot account for, making the backfill necessary for as long as such a callback may be open. That only holds while the canister is still short of the gauge. The call fee is part of the gauge, so the first backfill credits it -- with the callback still open, `consumed_cycles - outstanding_prepayments()` already includes it -- and the refund path then adds exactly the prepayments that `outstanding_prepayments()` reported, leaving the two in step. Backfill before executing the response in the test to pin that down. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
The checkpoint migration depends on intricate cross-crate cycles-accounting invariants that warrant final human validation.
Review details
- Files reviewed: 8/8 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
✅ No security or compliance issues detected. Reviewed everything up to e349119. Security Overview
Detected Code Changes
|
schneiderstefan
left a comment
There was a problem hiding this comment.
I wonder if this PR actually makes it better. Previously, there was a disconnect between the monotonic and the non-monotonic metrics. But the monotonic metrics and the per-usecase monotonic metrics (consumed_cycles_by_use_cases_monotonic) were consistent. Now this is no longer true, and we don't have the information to backfill the per-usecase metrics.
At the very least, this should be documented as a shortfall of the per-usecase metrics.
Not really because the monotonic aggregate was only introduced on Tuesday by #11465 and thus its value is currently lower than the aggregate over the monotonic per-use-case break-down.
We do have it and I plan to follow-up on this in a separate PR. We only need to backfill the |
The `None` arm of the monotonic consumed-cycles backfill `Debug`-printed the paused task, which for a paused ingress execution embeds the whole method payload. Describe the task in words instead, using the payload-free `Display` of its input. Also drop `SystemStateTesting::with_raw_callback`, whose only caller moved to the response tests and now uses `CallContextManagerTesting::with_callback`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
The accounting migration spans checkpoint scheduling, legacy state compatibility, refunds, and persisted metrics, requiring final human validation.
Review details
- Files reviewed: 8/8 changed files
- Comments generated: 0 new
- Review effort level: Balanced
CanisterMetrics::consumed_cycles_monotonic(added in #11465) is absent incheckpoints written before it, and decodes as zero. Left alone it would only
start accruing from the round it was added in, so every canister that predates
it under-reports its lifetime consumption forever.
The
consumed_cyclesgauge predates it and holds the full history, so themonotonic value can be derived from it.
SystemState::outstanding_prepayments()sums the prepayments that have been added to the gauge but whose refund has not
been observed yet -- the ones held in an open
Callback(
prepayment_for_response_executionplusprepayment_for_call_transmission,falling back to
prepayment_for_response_transmissionfor callbacks createdbefore April 2026, as the refund path itself does) or in the
prepaid_execution_cyclesof an aborted execution orinstall_code. All ofthem are recorded in the replicated state, which yields the invariant
whenever no execution is in progress or paused, and makes
SystemState::migrate_consumed_cycles_to_monotonic()exact. Because theinvariant holds at every checkpoint rather than just the first one, the backfill
is also idempotent: a no-op for a canister already backfilled, and self-healing
if a downgrade dropped the field.
A callback created before
prepayment_for_call_transmissionwas stored incallbacks (#9859, April 2026) is no exception, even though the fallback accounts
for the response transmission part of that prepayment only, leaving out the
xnet_call_performed_feeplus the request bytes fee. That call fee is part ofthe gauge, so the first backfill credits it while the callback is still open --
the gauge being what the canister really consumed -- and executing the response
then adds exactly the prepayments that
outstanding_prepayments()reported,keeping the monotonic value in step with the gauge. Only a canister still short
of the gauge, i.e. one not backfilled yet or whose field a downgrade dropped,
lags by that fee, which is the case the backfill exists for anyway.
The scheduler runs it on checkpoint rounds only, after
abort_all_paused_executions: a paused execution generally holds its prepaymentin memory rather than in the state, and backfilling from a gauge that covers it
would overestimate, so
outstanding_prepayments()returnsNonefor any pausedexecution -- including a paused response execution, whose prepayments the
callback carried by the task does record, since the only caller runs once there
are none left. Aborting materializes those prepayments into the task queues. The
pass takes a mutable reference to a canister only when it has something to
write, so it stays read-only once the subnet has been backfilled.
The three states this considers unreachable -- a canister whose outstanding
prepayments cannot be derived, outstanding prepayments above the gauge, and a
monotonic value already above the gauge net of them -- are reported through
debug_assert_or_critical_error!and a newscheduler_consumed_cycles_invariant_brokenerror counter, since all of themwould otherwise be silent in production.
debug_assert_or_critical_error!nowpasses its message to the
debug_assert!as well, so the panic it raises underdebug assertions names the report that tripped rather than reading
assertion failed: false; that is what lets the tests here expect one specificreport.
The tests span the three levels this touches:
outstanding_prepayments()andthe backfill are covered by unit tests in
rs/replicated_state; scheduler testsdrive real prepayments through checkpoint rounds, a response callback paused and
then aborted among them, to assert that the abort leaves behind a task that
prepays nothing of its own and a callback that is no longer registered; and an
ExecutionTestexecutes the response of a legacy callback, tying the fallbackof
outstanding_prepayments()to the one thatResponseHelper::apply_initial_refunds()applies -- the two fall backindependently of each other, in different crates -- and asserts that a backfill
taken while that callback is still open credits its call fee, so that executing
the response leaves the monotonic value in step with the gauge. Turning a callback into a legacy
one needs a new
reset_prepayment_for_call_transmissiontesting hook, as noproduction code writes such a callback any more.
No metric or behaviour changes: nothing outside of tests reads
consumed_cycles_monotonicyet. A follow-up(
mraszyk/consumed-cycles-as-counter, #11416) exports the monotonic total underthe existing
replicated_state_consumed_cycles_since_replica_startedgauge andis stacked on this branch.
🤖 Generated with Claude Code