Skip to content

Drain per-host reservation when a VM starts on a different host - #13363

Open
Kukunin wants to merge 4 commits into
apache:mainfrom
Kukunin:fix-stale-host-reservation-on-cross-host-start
Open

Drain per-host reservation when a VM starts on a different host#13363
Kukunin wants to merge 4 commits into
apache:mainfrom
Kukunin:fix-stale-host-reservation-on-cross-host-start

Conversation

@Kukunin

@Kukunin Kukunin commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

What's happening

When a VM is stopped via the API, CloudStack moves its CPU/RAM from used to reserved on its last_host_id (see the Stopping → Stopped + OperationSucceeded branch in CapacityManagerImpl.postStateTransitionEvent). The idea is that a quick restart on the same host can reclaim its earmarked slot cheaply.

The asymmetry: when the VM later starts on the same host, the fromLastHost=true branch in allocateVmCapacity drains that reservation. When it starts on a different host, the old reservation just sits there. It only clears when:

  • capacity.skipcounting.hours (default 1h) elapses and updateCapacityForHost recycles it, or
  • the VM is destroyed/expunged.

Until then, the orphan reservation gets summed into the cluster's used + reserved aggregate by FirstFitPlanner.removeClustersCrossingThreshold. On a cluster that's already near cluster.memory.allocated.capacity.disablethreshold (default 0.85), the phantom can trip the threshold and block subsequent VM starts in the whole cluster — even though the VM in question isn't actually consuming anything on its old host.

We hit this on a fairly full cluster where stop-then-start cycles started failing intermittently with InsufficientServerCapacityException: No destination found. The "ghost" capacity was the released-but-not-drained reservation from the last stop.

The fix

postStateTransitionEvent now drains the VM's reservation on its previous host before allocating on the target host — regardless of whether the target is the same host or a different one. Treating both cases identically removes the fromLastHost asymmetry.

if ((newState == State.Starting || newState == State.Migrating || event == Event.AgentReportMigrated) && vm.getHostId() != null) {
    if (vm.getLastHostId() != null) {
        releaseVmCapacity(vm, true, false, vm.getLastHostId());
    }
    allocateVmCapacity(vm);
}

Side effects of unifying the path:

  • The fromLastHost=true branch in allocateVmCapacity is now unreachable from postStateTransitionEvent. The only other caller (VirtualMachineManagerImpl#reconfiguringOnExistingHost) already passes false, so the parameter is removed entirely.
  • Fixes the long-standing moveToReservered typo (3 e's) — renamed to moveToReserved throughout the interface, the impl, and the debug logs.
  • One logger.debug(String.format(...)) in postStateTransitionEvent switched to SLF4J {} placeholders to match the rest of the file.

Tests

Two new tests in CapacityManagerImplTest cover both transitions:

  • testPostStateTransitionReleasesStaleReservationWhenStartingOnDifferentHost — fails on main, passes with the fix. Verifies releaseVmCapacity(vm, true, false, oldHostId) is invoked.
  • testPostStateTransitionReleasesReservationWhenStartingOnSameHost — guards the unified contract so both paths stay in lockstep.

Manual validation

Reproduced on a small test cluster:

  1. Deploy a VM on host A, stop it (reserved=128M appears on A in op_host_capacity).
  2. Force a start on host B via hostid.
  3. Logs show the inline drain: release mem from host: A, old reserved: 128MB → new reserved: 0.
  4. After the VM reaches Running on B, last_host_id updates to B and updateCapacityForHost agrees the reservation is gone.

Same-host stop/start round-trip continues to behave the same way as before.

When a VM is stopped via the API, postStateTransitionEvent moves its
used capacity into reserved_capacity on its last host (so a quick
restart can reclaim it cheaply). Until now, this reservation was only
drained when the VM started again on the same lastHostId. Starting on
any other host left an orphan reservation that lingered for up to one
hour (capacity.skipcounting.hours) and was summed into the cluster
used+reserved aggregate by FirstFitPlanner.removeClustersCrossingThreshold,
spuriously tripping the disable-threshold and blocking later starts.

Always drain the VM's reservation on its previous host before
allocating on the target host — same host or not. This removes the
fromLastHost branch (now dead, the only other caller already passes
false), the matching boolean parameter on allocateVmCapacity, and the
misspelt moveToReservered parameter everywhere it appears.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@DaanHoogland

Copy link
Copy Markdown
Contributor

@blueorangutan package

@blueorangutan

Copy link
Copy Markdown

@DaanHoogland a [SL] Jenkins job has been kicked to build packages. It will be bundled with no SystemVM templates. I'll keep you posted as I make progress.

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.

Pull request overview

This PR fixes a capacity-accounting asymmetry where per-host reserved CPU/RAM left behind on a VM’s previous host could linger after the VM starts on a different host, inflating used+reserved and potentially blocking subsequent placements at the cluster threshold.

Changes:

  • Drain the VM’s reserved capacity on its last_host_id during relevant state transitions before allocating capacity on the target host.
  • Simplify capacity allocation by removing the now-unreachable fromLastHost path and updating callers accordingly.
  • Add unit tests to ensure reservations are released for both same-host and different-host starts; also standardize logging and fix a long-standing moveToReservered typo.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
server/src/main/java/com/cloud/capacity/CapacityManagerImpl.java Unifies reservation draining behavior on start/migrate transitions; removes fromLastHost logic; adjusts logging/typo.
engine/components-api/src/main/java/com/cloud/capacity/CapacityManager.java Updates the public API to remove the allocateVmCapacity(..., fromLastHost) parameter and fixes the moveToReservered typo.
engine/orchestration/src/main/java/com/cloud/vm/VirtualMachineManagerImpl.java Updates call site to the new allocateVmCapacity(vm) signature.
server/src/test/java/com/cloud/capacity/CapacityManagerImplTest.java Adds regression tests covering reservation draining on same-host and different-host starts.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 174 to +178
HostVO host = _hostDao.findById(hostId);
if (HypervisorType.External.equals(host.getHypervisorType())) {
return true;
}
return releaseVmCapacity(vm, moveFromReserved, moveToReservered, host);
return releaseVmCapacity(vm, moveFromReserved, moveToReserved, host);
Comment on lines 942 to 944
Host lastHost = _hostDao.findById(vm.getLastHostId());
Host oldHost = _hostDao.findById(oldHostId);
Host newHost = _hostDao.findById(vm.getHostId());
Comment on lines 985 to 990
if ((newState == State.Starting || newState == State.Migrating || event == Event.AgentReportMigrated) && vm.getHostId() != null) {
boolean fromLastHost = false;
if (vm.getHostId().equals(vm.getLastHostId())) {
logger.debug("VM starting again on the last host it was stopped on");
fromLastHost = true;
if (vm.getLastHostId() != null) {
releaseVmCapacity(vm, true, false, vm.getLastHostId());
}
allocateVmCapacity(vm, fromLastHost);
allocateVmCapacity(vm);
}
@blueorangutan

Copy link
Copy Markdown

Packaging result [SF]: ✔️ el8 ✔️ el9 ✔️ el10 ✔️ debian ✔️ suse15. SL-JID 18263

Two defensive fixes from Copilot's review of PR apache#13363:

1. releaseVmCapacity(VM, ..., Long hostId): _hostDao.findById(hostId)
   can return null when last_host_id points to a deleted host. The
   subsequent host.getHypervisorType() check would NPE. The Host
   overload already null-checks AND has the External check, so
   delegate to it instead of duplicating the External check here.

2. postStateTransitionEvent: the lastHost was being fetched twice
   (once for logging at L942, again inside releaseVmCapacity via
   the Long overload at L987). Reuse the already-resolved Host. The
   Host overload also handles null, so the explicit null check on
   getLastHostId() becomes redundant.

(Skipped Copilot's third suggestion to guard findById(null) — the
GenericDaoBase.findById(ID, boolean, Boolean) implementation at
GenericDaoBase.java:1066 explicitly returns null for null id, so
the existing code is already safe.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@DaanHoogland

Copy link
Copy Markdown
Contributor

@blueorangutan package

@blueorangutan

Copy link
Copy Markdown

@DaanHoogland a [SL] Jenkins job has been kicked to build packages. It will be bundled with no SystemVM templates. I'll keep you posted as I make progress.

@codecov

codecov Bot commented Jun 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 18.75000% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 19.89%. Comparing base (72014a0) to head (65ddaf7).

Files with missing lines Patch % Lines
...n/java/com/cloud/capacity/CapacityManagerImpl.java 20.00% 12 Missing ⚠️
...n/java/com/cloud/vm/VirtualMachineManagerImpl.java 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff            @@
##               main   #13363   +/-   ##
=========================================
  Coverage     19.89%   19.89%           
- Complexity    20141    20143    +2     
=========================================
  Files          6371     6371           
  Lines        576829   576813   -16     
  Branches      70627    70621    -6     
=========================================
+ Hits         114756   114775   +19     
+ Misses       449531   449489   -42     
- Partials      12542    12549    +7     
Flag Coverage Δ
uitests 3.71% <ø> (ø)
unittests 21.16% <18.75%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@blueorangutan

Copy link
Copy Markdown

Packaging result [SF]: ✔️ el8 ✔️ el9 ✔️ el10 ✔️ debian ✔️ suse15. SL-JID 18295

@DaanHoogland

Copy link
Copy Markdown
Contributor

@blueorangutan test

@blueorangutan

Copy link
Copy Markdown

@DaanHoogland a [SL] Trillian-Jenkins test job (ol8 mgmt + kvm-ol8) has been kicked to run smoke tests

@blueorangutan

Copy link
Copy Markdown

[SF] Trillian test result (tid-16368)
Environment: kvm-ol8 (x2), zone: Advanced Networking with Mgmt server ol8
Total time taken: 51503 seconds
Marvin logs: https://github.com/blueorangutan/acs-prs/releases/download/trillian/pr13363-t16368-kvm-ol8.zip
Smoke tests completed. 150 look OK, 1 have errors, 0 did not run
Only failed and skipped tests results shown below:

Test Result Time (s) Test File
ContextSuite context=TestClusterDRS>:setup Error 0.00 test_cluster_drs.py

@Pearl1594 Pearl1594 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.

LGTM - Verified the capacity fix manually. Stopped a VM on host1, confirmed its used capacity moved to reserved (memory/cpu/cpu_core), then started it on host2 and confirmed host1's reserved capacity dropped back to 0 while host2's used capacity picked up correctly, no double-counting.

Copilot AI review requested due to automatic review settings September 10, 2026 15:51

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 core capacity-accounting behavior during VM lifecycle transitions, which is operationally sensitive and warrants final human review despite the added unit tests.

Review details
  • Files reviewed: 4/4 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@Pearl1594

Copy link
Copy Markdown
Contributor

@blueorangutan package

@blueorangutan

Copy link
Copy Markdown

@Pearl1594 a [SL] Jenkins job has been kicked to build packages. It will be bundled with no SystemVM templates. I'll keep you posted as I make progress.

@blueorangutan

Copy link
Copy Markdown

Packaging result [SF]: ✖️ el8 ✖️ el9 ✖️ debian ✖️ suse15. SL-JID 19209

@Pearl1594

Copy link
Copy Markdown
Contributor

@blueorangutan package

@blueorangutan

Copy link
Copy Markdown

@Pearl1594 a [SL] Jenkins job has been kicked to build packages. It will be bundled with no SystemVM templates. I'll keep you posted as I make progress.

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 new unconditional reserved-capacity drain during migration-related transitions can incorrectly subtract aggregate reserved capacity on a host for VMs that were never reserved there, corrupting capacity accounting.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 4/4 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment on lines 982 to 985
if ((newState == State.Starting || newState == State.Migrating || event == Event.AgentReportMigrated) && vm.getHostId() != null) {
boolean fromLastHost = false;
if (vm.getHostId().equals(vm.getLastHostId())) {
logger.debug("VM starting again on the last host it was stopped on");
fromLastHost = true;
}
allocateVmCapacity(vm, fromLastHost);
releaseVmCapacity(vm, true, false, lastHost);
allocateVmCapacity(vm);
}
@blueorangutan

Copy link
Copy Markdown

Packaging result [SF]: ✔️ el8 ✔️ el9 ✔️ el10 ✔️ debian ✔️ suse15. SL-JID 19213

@Pearl1594

Copy link
Copy Markdown
Contributor

@blueorangutan test

@blueorangutan

Copy link
Copy Markdown

@Pearl1594 a [SL] Trillian-Jenkins test job (ol8 mgmt + kvm-ol8) has been kicked to run smoke tests

Comment on lines 982 to +984
if ((newState == State.Starting || newState == State.Migrating || event == Event.AgentReportMigrated) && vm.getHostId() != null) {
boolean fromLastHost = false;
if (vm.getHostId().equals(vm.getLastHostId())) {
logger.debug("VM starting again on the last host it was stopped on");
fromLastHost = true;
}
allocateVmCapacity(vm, fromLastHost);
releaseVmCapacity(vm, true, false, lastHost);
allocateVmCapacity(vm);

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 believe this comment is valid and needs attention. reservedCapacity on a host is only ever populated when a VM is stopped there. A running VM's footprint is never moved into that bucket. So the release should be gated on oldState == State.Stopped rather than on newState: that still covers Stopped -> Starting (restart) and Stopped -> Migrating (storage migration
of a stopped VM), both of which genuinely have reserved capacity to release on lastHost, while skipping it for live migration (Running -> Migrating) and AgentReportMigrated, where the source host's footprint is in used, not reserved;releasing there was decrementing unrelated reserved capacity that happened to exist on that host, since it was never this VM's to begin with.

  if ((newState == State.Starting || newState == State.Migrating || event == Event.AgentReportMigrated) && vm.getHostId() != null) {
    if (oldState == State.Stopped) {
      releaseVmCapacity(vm, true, false, lastHost);
    }
    allocateVmCapacity(vm);
  }

@blueorangutan

Copy link
Copy Markdown

[SF] Trillian test result (tid-16976)
Environment: kvm-ol8 (x2), zone: Advanced Networking with Mgmt server ol8
Total time taken: 53652 seconds
Marvin logs: https://github.com/blueorangutan/acs-prs/releases/download/trillian/pr13363-t16976-kvm-ol8.zip
Smoke tests completed. 155 look OK, 1 have errors, 0 did not run
Only failed and skipped tests results shown below:

Test Result Time (s) Test File
ContextSuite context=TestClusterDRS>:setup Error 0.00 test_cluster_drs.py

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Ready

Development

Successfully merging this pull request may close these issues.

7 participants