Skip to content

feat: Backfill the canister-level monotonic consumed cycles - #11490

Merged
mraszyk merged 20 commits into
masterfrom
mraszyk/backfill-consumed-cycles-monotonic
Sep 11, 2026
Merged

feat: Backfill the canister-level monotonic consumed cycles#11490
mraszyk merged 20 commits into
masterfrom
mraszyk/backfill-consumed-cycles-monotonic

Conversation

@mraszyk

@mraszyk mraszyk commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

CanisterMetrics::consumed_cycles_monotonic (added in #11465) is absent in
checkpoints 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_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
(prepayment_for_response_execution plus prepayment_for_call_transmission,
falling back to prepayment_for_response_transmission for callbacks created
before April 2026, as the refund path itself does) 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

whenever no execution is in progress or paused, and makes
SystemState::migrate_consumed_cycles_to_monotonic() exact. Because the
invariant 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_transmission was stored in
callbacks (#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_fee plus the request bytes fee. That call fee is part of
the 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 prepayment
in memory rather than in the state, and backfilling from a gauge that covers it
would overestimate, so outstanding_prepayments() returns None for any paused
execution -- 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 new
scheduler_consumed_cycles_invariant_broken error counter, since all of them
would otherwise be silent in production. debug_assert_or_critical_error! now
passes its message to the debug_assert! as well, so the panic it raises under
debug assertions names the report that tripped rather than reading
assertion failed: false; that is what lets the tests here expect one specific
report.

The tests span the three levels this touches: outstanding_prepayments() and
the backfill are covered by unit tests in rs/replicated_state; scheduler tests
drive 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
ExecutionTest executes the response of a legacy callback, tying the fallback
of outstanding_prepayments() to the one that
ResponseHelper::apply_initial_refunds() applies -- the two fall back
independently 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_transmission testing hook, as no
production code writes such a callback any more.

No metric or behaviour changes: nothing outside of tests reads
consumed_cycles_monotonic yet. A follow-up
(mraszyk/consumed-cycles-as-counter, #11416) exports the monotonic total under
the existing replicated_state_consumed_cycles_since_replica_started gauge and
is stacked on this branch.

🤖 Generated with Claude Code

`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>

Copilot AI left a comment

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.

🟡 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.

Comment thread rs/execution_environment/src/scheduler.rs
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>

Copilot AI left a comment

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.

🔵 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>

Copilot AI left a comment

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.

🟡 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

Comment thread rs/execution_environment/src/canister_manager.rs Outdated
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>

Copilot AI left a comment

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.

🟡 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

Comment thread rs/execution_environment/src/scheduler.rs
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>

Copilot AI left a comment

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.

🔵 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

mraszyk and others added 5 commits September 8, 2026 14:14
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>

Copilot AI left a comment

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.

🔵 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_panic can pass on any setup assertion failure before the checkpoint round (for example, if outstanding unexpectedly becomes zero), without exercising the invariant-error branch. Constrain the expected panic from debug_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_panic also treats failures in the ingress/setup assertions as success, so the test can pass without reaching the Ordering::Less report. Constrain it to the panic emitted by the target debug_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

mraszyk and others added 3 commits September 9, 2026 08:48
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>

Copilot AI left a comment

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.

🔵 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>

Copilot AI left a comment

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.

🔵 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>

Copilot AI left a comment

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.

🟢 Approval recommended

The migration is guarded, idempotent, and comprehensively covers outstanding and legacy prepayment states.

Review details
  • Files reviewed: 8/8 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@mraszyk
mraszyk marked this pull request as ready for review September 10, 2026 15:39
@mraszyk
mraszyk requested a review from a team as a code owner September 10, 2026 15:39
@zeropath-ai

zeropath-ai Bot commented Sep 10, 2026

Copy link
Copy Markdown

No security or compliance issues detected. Reviewed everything up to e349119.

Security Overview
Detected Code Changes
Change Type Relevant files
Enhancement ► rs/execution_environment/src/execution/response/tests.rs
      Add test execute_response_of_legacy_callback_settles_the_outstanding_prepayments
► rs/execution_environment/src/scheduler.rs
      Import additional debug_assert_or_critical_error and migrate backfill logic on checkpoint rounds
► rs/execution_environment/src/scheduler/scheduler_metrics.rs
      Add consumed_cycles_invariant_broken metric
► rs/execution_environment/src/scheduler/tests/metrics.rs
      Introduce tests for consumed_cycles invariant and checkpoint backfill behavior
► rs/execution_environment/src/util.rs
      Update debug_assert_or_critical_error macro to include formatted message
► rs/replicated_state/src/canister_state/system_state.rs
      Add outstanding_prepayments() method documentation and logic
► rs/replicated_state/src/canister_state/system_state/call_context_manager.rs
      Expose reset_prepayment_for_call_transmission in testing interface
► rs/replicated_state/src/canister_state/tests.rs
      Add tests for outstanding_prepayments_of_open_callbacks and related scenarios
► rs/replicated_state/src/canister_state/system_state.rs
      Add detailed comments and new methods related to prepayments and monotonic backfill
Refactor ► rs/execution_environment/src/scheduler.rs
      Rearrange imports and align backfill call with new migrate_consumed_cycles_to_monotonic function
► rs/execution_environment/src/scheduler/scheduler_metrics.rs
      Integrate new invariant-related metric into SchedulerMetrics construction

@schneiderstefan schneiderstefan left a comment

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.

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.

@mraszyk

mraszyk commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

But the monotonic metrics and the per-usecase monotonic metrics (consumed_cycles_by_use_cases_monotonic) were consistent.

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.

Now this is no longer true, and we don't have the information to backfill the per-usecase metrics.

We do have it and I plan to follow-up on this in a separate PR. We only need to backfill the Instructions and RequestAndResponseTransmission use cases (the others are charged directly without any non-monotonic prepayment/refund separation) and we have the information available - Instructions as prepaid instructions in callbacks and aborted executions and RequestAndResponseTransmission as prepayments for response transmission in callbacks (this PR sums them all up to update the gauge and backfill the aggregate, the follow-up PR would separately update the Instructions gauge by deducting prepaid instructions and the ReqeustAndResponseTransmissions gauge by deducting prepaid response transmission cycles - the request tranmission cycles being already settled in the gauge as noted on this PR, too).

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>

Copilot AI left a comment

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.

🔵 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

@mraszyk
mraszyk enabled auto-merge September 11, 2026 14:35
@mraszyk
mraszyk added this pull request to the merge queue Sep 11, 2026
Merged via the queue into master with commit 4b629b4 Sep 11, 2026
45 checks passed
@mraszyk
mraszyk deleted the mraszyk/backfill-consumed-cycles-monotonic branch September 11, 2026 16:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants