Skip to content

fix: don't report the dropped ingress induction debit as consumed - #11272

Draft
mraszyk wants to merge 4 commits into
masterfrom
mraszyk/ingress-induction-debit-consumed-cycles
Draft

fix: don't report the dropped ingress induction debit as consumed#11272
mraszyk wants to merge 4 commits into
masterfrom
mraszyk/ingress-induction-debit-consumed-cycles

Conversation

@mraszyk

@mraszyk mraszyk commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

apply_ingress_induction_cycles_debit() may be called with a cycles balance that is smaller than the pending debit: after a cleanup callback, which is allowed to burn the balance below the debit and must always be able to succeed. As documented, the part of the debit that the balance cannot cover is then dropped, making some of the postponed ingress induction charges free.

The dropped part was still reported as consumed, though: the full debit was passed to consume_cycles(), which saturates the balance subtraction at zero but records the full nominal amount in the consumed cycles metrics. The canister (and, transitively, the subnet) therefore over-reported consumed cycles by the dropped amount.

Charge only the part of the debit that the balance can cover, so that the consumed cycles metrics match the cycles actually removed from the balance.

Additionally, harden the implementation as follows:
Compute the part that the balance cannot cover before draining it and report
only the charged remainder, via the new CompoundCycles::minus_uncharged(),
which reduces both the real and the nominal part.

This makes the fix in apply_ingress_induction_cycles_debit() from the
previous commit hold for every use case, so update its comment accordingly:
charging the covered part explicitly now only serves to keep the dropped debit
visible at that call site.

`apply_ingress_induction_cycles_debit()` may be called with a cycles balance
that is smaller than the pending debit: after a cleanup callback, which is
allowed to burn the balance below the debit and must always be able to
succeed. As documented, the part of the debit that the balance cannot cover
is then dropped, making some of the postponed ingress induction charges free.

The dropped part was still reported as consumed, though: the full debit was
passed to `consume_cycles()`, which saturates the balance subtraction at zero
but records the full nominal amount in the consumed cycles metrics. The
canister (and, transitively, the subnet) therefore over-reported consumed
cycles by the dropped amount.

Charge only the part of the debit that the balance can cover, so that the
consumed cycles metrics match the cycles actually removed from the balance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the fix label Aug 24, 2026
mraszyk and others added 3 commits August 24, 2026 09:26
`consume_cycles()` subtracts the real amount from the balances with saturating
arithmetic, but reported the full nominal amount as consumed. Whenever the
balances cannot cover the request, the difference is never removed from any
balance, yet it still showed up in the canister's (and, transitively, the
subnet's) consumed cycles metrics.

Compute the part that the balance cannot cover before draining it and report
only the charged remainder, via the new `CompoundCycles::minus_uncharged()`,
which reduces both the real and the nominal part. The two parts coincide under
the normal cost schedule; under the free cost schedule the real part is zero,
so nothing can be left uncharged and this is a no-op.

This makes the fix in `apply_ingress_induction_cycles_debit()` from the
previous commit hold for every use case, so update its comment accordingly:
charging the covered part explicitly now only serves to keep the dropped debit
visible at that call site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`consume_cycles()` charges only what the balances can cover and silently caps
the consumed cycles metrics accordingly. Silently is the problem: for every
caller that verified the balance beforehand, a shortfall is a bug that nobody
gets to see.

Return the uncharged amount and mark `consume_cycles()` `#[must_use]`, so that
callers holding a logger and an error counter can report it as a critical
error, and the rest have to acknowledge that they are ignoring it.

`apply_ingress_induction_cycles_debit()` is such a caller: it now passes the
full debit and drives its existing `[EXC-BUG]` report off the returned amount
instead of pre-computing the covered part, making the return value the single
source of truth for what was dropped. The report stays gated on `strict`, as
dropping the debit that the balance cannot cover after a cleanup callback is
legitimate and must remain silent.

This also removes a false positive: postponed ingress induction charges are
recorded unadjusted, so under the free cost schedule the previous nominal
comparison could exceed the balance and trip the strict `debug_assert` and the
error log even though nothing had to be charged. The returned amount is derived
from the real part, which is zero under the free cost schedule.

The remaining callers cover the requested amount by construction: the uninstall
burn asserts it, the others acknowledge the result where they explain why the
balance suffices.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…es()`

Returning the uncharged amount from `consume_cycles()` left every caller with a
`let _uncharged = ...` to acknowledge, and only the one caller that happened to
hold a logger and an error counter could actually report a shortfall.

Take the logger and the critical error counter in `consume_cycles()` instead and
report there: the balances are now required to cover the requested amount, and
whatever they cannot cover increments the counter and is logged as `[EXC-BUG]`,
next to the cycles use case and the canister that could not pay. The consumed
cycles metrics still only account for what was charged.

Callers that legitimately charge only what the balances can cover must cap the
amount themselves. `apply_ingress_induction_cycles_debit()` is the only such
caller: it caps the debit with `CompoundCycles::minus_uncharged()`, keeping its
own `[EXC-BUG]` report for the `strict` case and dropping the rest silently
after a cleanup callback, as documented.

Plumbing the logger and the counter to the remaining callers follows the
existing convention of passing a component's critical error counter to the code
that may hit it:

* `CanisterManager` holds one next to its logger, registered as the
  `canister_manager_charging_from_balance` critical error;
* `ValidSetRuleImpl` registers `mr_charging_from_balance` for the ingress
  induction charge;
* the scheduler and the execution paths pass
  `execution_environment_charging_from_balance`, exposed on
  `ExecutionEnvironment` next to the existing `state_changes_error` accessor;
* applying the system state modifications passes the state changes critical
  error, the counter that path already carries;
* the cycles account manager forwards both to `SystemState::consume_cycles()`
  from the charging methods that reach it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant