Postmortem: High Rate of Ingress Expiration on the European Subnet (bkfrj)
Executive summary
On March 5th, 2026, the European Subnet (bkfrj) experienced excessive latency (on the order of 30 minutes) after being upgraded to replica version 781ef50. The issue was caused by a scheduler refactoring touching behavior not covered by tests (frozen canisters being charged scheduler priority for messages they cannot execute). Things returned to normal after the subnet was reverted (to replica version de43a37).
Scope and severity of impact
-
One affected subnet (bkfrj).
-
The incident lasted for 2.5 hours and was solved by simply reverting the replica upgrade.
-
Apart from automated traffic generated every 4 minutes or so by timers, the subnet gets very little traffic (on the order of 0.5 transactions per second).
-
Fewer than 0.1 ingress messages per second expired (just under 20% of the ingress traffic).
Timeline of events (UTC)
Key Events (all times UTC; delay since incident start in parenthesis)
2026-03-05:
-
10:58: Incident start (subnet bkfrj upgraded).
-
11:48: Issue Detected. (+0:50)
-
11:48: Issue reported by users on forum. (+0:50)
-
12:22: Inspection of Execution Metrics dashboard shows that timers are slow to fire (+1:24)
-
12:46: Suggestion to roll back the upgrade (+1:48)
-
12:47: Other subnet upgrades paused (+1:49)
-
12:59: Decision reached to roll back the upgrade (+2:01)
-
13:13: Rollback proposal out (+2:15)
-
13:23: Rollback complete, behavior returns to normal (+2:25)
-
14:35: Incident closed (+3:36)
Lessons learned
Replica version rollbacks are fast
Once the decision to roll back the replica upgrade was made, it took less than 25 minutes to get the subnet back into fully functioning state. This obviously only works if the subnet is not stalled, but it’s something to keep in mind.
Refactoring untested code is dangerous
The issue was introduced while refactoring the canister scheduler to be more efficient when scheduling relatively few active canisters from among a huge number of hosted canisters (by far the most common case). The existing scheduler (actually better described as a mix of control loop plus scheduler) is monolithic.
And the tests, while sizable (6.6k LOC) all test what is essentially end-to-end behavior. They are also an organic collection that grew over time, so there is very little structure to them. It is hard to tell what is being tested and what not.
The breaking change touched an aspect of the control loop logic (canisters without enough cycles to actually execute anything) that, while hinted at in a couple of comments, was entirely untested. But the sheer volume of tests and their lack of structure made it impossible to make this determination.
Good test coverage for a large refactoring is hard to write, but critical
In the same vein, tests that cover old, organically grown code with new, differently behaved code are difficult to write. For one, it can be (and in this case it is) very hard to fully grasp all the explicit and implicit behaviors of the old code. However, writing some high-level tests ahead of time and ensuring at least nominal coverage of all existing code would have likely helped.
IC subnets are weird and wonderful
Subnet bkfrj has over 4000 (potentially quite a bit more) canisters with timers and without enough cycles to execute anything. Because these timers all triggered a long time ago and were never executed, they are enqueued for execution every single round. Meaning that every round (with or without a broken scheduler) ends up “executing” 4000 timers (by doing absolutely nothing with them) and terminating.
Action items
Ensure comprehensive scheduler testing before proceeding further with the refactoring (see #9270, #9307, #9329, #9343, #9351, #9381, #9402, #9438, #9457).
Technical details
Subnet bkfrj currently has over 4k canisters with heartbeats but not enough cycles to execute them (or anything else). This means that even in the absence of any other load, the subnet reaches the 8B instruction limit and stops executing anything else simply from the 2M instruction overhead that we account for every (executed or not) message.
Aiming for efficiency while charging for “full round executions”, the breaking change switched from iterating over all canisters and checking for “nothing left to execute” to only iterating over canisters that had actually completed at least one message execution during the round. The 4k canisters above however, all had something to execute at the beginning of each round; nothing left to execute at the end; and had not executed anything in-between. Comments in the code mention “canisters with too few cycles” being unable to actually execute messages (and there’s even a return value for execute_canister() signifying “nothing was executed because there weren’t enough cycles”); but only one test touched on this behavior and only to the extent that the message was not executed.