Skip to content

engine: do not orphan an instance on a forced power report during start - #14207

Open
bhouse-nexthop wants to merge 11 commits into
apache:4.22from
bhouse-nexthop:fix-orphaned-vm-power-report-race
Open

bhouse-nexthop wants to merge 11 commits into
apache:4.22from
bhouse-nexthop:fix-orphaned-vm-power-report-race

Conversation

@bhouse-nexthop

@bhouse-nexthop bhouse-nexthop commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Description

An instance can end up running on a KVM host with no record in CloudStack. Its IP address is released and later handed to another instance, so two instances answer for the same address. Its root volume stays in Destroy and cannot be deleted while the domain holds it.

Fixes: #14206

The trigger is the out-of-band ping the KVM agent sends when another instance on the same host shuts down or crashes. That ping is processed with force, which skips the graceful period. The KVM report lists only powered-on domains, so an instance that is still starting is absent from it and is marked PowerReportMissing.

Sequence:

time what happens
20:23:5x DeployVM starts, instance -> Starting, StartCommand sent
20:25:14 another instance crashes; agent collects a report and sends it with force
20:25:16 the new domain is created on the host
20:25:17 StartAnswer arrives, instance -> Running
20:25:18 the 20:25:14 report is processed; instance is not in it -> PowerReportMissing
NICs and IP released, instance -> Stopped. No StopCommand is sent
20:25:19 the deploy job finds it Stopped, logs an error, instance -> Error, job fails
20:25:28 the failed deploy is followed by an automatic destroy with expunge; advanceStop() returns at once because state is Error
20:25:39 expunged and removed. No StopCommand was ever sent

One commit per finding, so they can be reviewed independently.

commit change
1 do not report an instance missing when its state changed within the graceful period, forced or not
2 send a StopCommand before releasing resources on a missing report, as the PowerOff branch already does
3 on a power-on report for a Destroyed or Expunging instance, stop it on the reporting host; alert for Error
4 send a StopCommand to the last known host before expunging, whatever the database state says
5 log instances a host reports that CloudStack has no record of at warn level, once per change
6 unit tests
7 build the new stop commands the way sendStop() does, so the host gets the VLAN persistence map, external details, control IP and volumes to disconnect
8 make the missing-report tests actually exercise the fix
9 do not force the stop for a missing report, so an unreachable host does not cause an address to be freed
10 raise an alert, not just a log line, for unknown instances
11 tests for the branches that decide whether an address is freed

Commit 1 is the root cause. Commits 2 to 4 are independent backstops, each of which would have prevented the end result on its own.

Behaviour that does not change:

  • an instance that has been Running for a while and then shuts itself down is still detected immediately, so out-of-band stop detection keeps working
  • nothing is stopped automatically for an unknown instance, it is only made visible, since an operator may have put a domain on the host deliberately

Known limitations

  • The guard compares wall-clock elapsed time against 2 x ping.interval. The exact invariant is "this report was collected before the last state change". Stamping the report with its collection time and comparing against update_time would be precise; that needs a field on PingRoutingCommand and is better done separately.
  • For an out-of-band migration, a successful StopAnswer from the old host proves the domain is not on that host, not that the instance is gone. Not a regression: the previous code released the resources unconditionally.
  • UserVmManagerImpl.expunge() calls _networkMgr.release() before advanceExpunge(), so by the time the expunge-time StopCommand is sent the addresses have already been released. Moving the stop ahead of that release would close the remaining window, in a separate change.

Types of changes

  • Breaking change (fix or feature that would cause existing functionality to change)
  • New feature (non-breaking change which adds functionality)
  • Bug fix (non-breaking change which fixes an issue)
  • Enhancement (improves an existing feature and functionality)
  • Cleanup (Code refactoring and cleanup, that may add test cases)
  • Build/CI
  • Test (unit or integration test code)

Feature/Enhancement Scale or Bug Severity

Bug Severity

  • BLOCKER
  • Critical
  • Major
  • Minor
  • Trivial

How Has This Been Tested?

  • unit tests added for the sync changes, VirtualMachinePowerStateSyncImplTest passes (11 tests)
  • the condition was traced on a running 4.22 deployment: agent logs show the out-of-band pings lining up one for one with the management server processing each report, including one collected two seconds before the domain existed and processed one second after the start completed
  • on the affected hosts the forced pings arrived every few seconds, so the graceful period almost never applied

How did you try to break this feature and the system with this change?

  • checked that checkBeforeCleanup is set correctly for each new StopCommand. It makes the host refuse to stop a running domain, so it is false where a running domain is what must be removed (commits 3 and 4) and true where the point is to back off if the instance turns out to be alive (commit 2)
  • checked the missing-report change does not weaken out-of-band stop detection. The guard only applies within the graceful period of a state change, and an instance that has been Running is well outside it
  • sendStop(..., force=true, ...) swallows AgentUnavailableException and OperationTimedoutException and answers success, so an unreachable host would have had its addresses released. Commit 9 passes force=false for a missing report; PowerOff keeps force=true, which is its existing behaviour
  • checked what an expunge costs now: it adds a StopCommand round trip for every instance, including ones already stopped. KVM answers "Domain not found" and still runs disk and interface cleanup, so it is correct but visible on busy hosts

A host VM state report lists only the instances running on the host at the
moment the report was collected. An out-of-band report (sent by the agent on
a libvirt lifecycle event) is processed with `force`, which skips both the
graceful period and the outdated-report filter. Any instance absent from that
report is immediately marked PowerReportMissing.

That is wrong when the report was collected before a state change the
management server made a moment later. Example:

| time     | event                                                    |
|----------|----------------------------------------------------------|
| 20:25:14 | another instance crashes; agent collects and sends report |
| 20:25:16 | the starting instance's domain is created                |
| 20:25:17 | StartAnswer arrives, instance -> Running                 |
| 20:25:18 | the 20:25:14 report is processed, instance not in it     |
|          | -> PowerReportMissing, instance -> Stopped               |

The instance is running but is now recorded as stopped. On a busy host these
forced reports are frequent, so the graceful period almost never applies.

Fix: skip the missing-report verdict for an instance whose state changed
within the graceful period, forced or not. A later report decides instead.
An instance that has been Running for a while and then shuts itself down is
unaffected, so out-of-band stop detection keeps working.

Also make the log line state whether the verdict was forced. It previously
said "has passed graceful period" even when `force` short-circuited the
check, which is misleading when diagnosing this.

Signed-off-by: Brad House <bhouse@nexthop.ai>
… missing report

When a power report for a Running instance is missing, the instance is synced
to Stopped and `releaseVmResources` frees its NICs and IP addresses. Unlike
the PowerOff branch above it, no StopCommand is sent.

A missing report only means the host did not list the instance. It is not
proof the instance is gone. When it is still running:

- its NIC and IP are marked free while it keeps using them
- the IP is later assigned to another instance
- two instances answer for the same address

Fix: use the same path as PowerOff. Send the StopCommand first and release
resources only if it succeeds. If the stop fails, keep the resources and let
a later report retry, rather than freeing an address that is still in use.

Signed-off-by: Brad House <bhouse@nexthop.ai>
When a host reports an instance as powered on and the database has it as
Destroyed or Expunging, the report is only logged. The instance keeps running
unmanaged:

- CloudStack knows the host and the instance name at that moment
- the instance's IP addresses and volumes have already been handed back
- nothing else ever looks at it again

Changes:

| state                | before | after                     |
|----------------------|--------|---------------------------|
| Destroyed, Expunging | log    | send StopCommand to host  |
| Error                | log    | log and raise an alert    |

Error is only alerted, not stopped, because an instance in that state may
still be wanted for inspection. Destroyed and Expunging are being deleted, so
there is no case for leaving them running.

Signed-off-by: Brad House <bhouse@nexthop.ai>
`advanceStop()` returns immediately, without sending anything to the host,
when the database already has the instance in one of these states:

    if (state == State.Stopped) return;
    if (state == State.Destroyed || state == State.Expunging || state == State.Error) return;

Expunge calls `advanceStop()` and then releases NICs and deletes volumes. So
for an instance recorded as Stopped or Error, expunge never contacts the host
at all. If the host is still running it, the instance keeps its addresses and
its disks are deleted underneath it.

`vm.destroy.forcestop` does not help: the early return happens before the
host id is even looked at.

Fix: after `advanceStop()`, send a StopCommand to the instance's host, or its
last known host, regardless of the database state. It is a no-op when no
domain is there.

Signed-off-by: Brad House <bhouse@nexthop.ai>
…bout

A host report naming an instance with no record in the database means
something is running there unmanaged, usually left behind by a deploy or an
expunge that never reached the host. Today that produces one debug line per
instance per report and nothing else, so it can go unnoticed indefinitely:

    Unable to find matched VM in CloudStack DB. name: i-2-3-VM powerstate: PowerOn

Change: log the unknown instances for a host at warn level, and only when the
set changes, so a standing condition is visible once instead of on every
report. A host that stops reporting unknown instances logs one info line.

Nothing is stopped automatically here. An unknown instance may be a domain an
operator put on the host deliberately, so this only makes the condition
visible; `listUnmanagedInstances` and import remain the way to act on it.

Signed-off-by: Brad House <bhouse@nexthop.ai>
Covers:

| test                                          | checks                                      |
|-----------------------------------------------|---------------------------------------------|
| hasRecentStateChange, within/outside/null      | the graceful period guard on state changes  |
| convertVmStateReport, known and unknown names  | unknown instances are skipped, not mapped   |
| convertVmStateReport, empty report             | no DB lookup for an empty report            |
| reportUnknownInstances, only reports changes   | one report per change, quiet while unchanged|

Signed-off-by: Brad House <bhouse@nexthop.ai>
@codecov

codecov Bot commented Sep 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 62.76596% with 35 lines in your changes missing coverage. Please review.
✅ Project coverage is 18.00%. Comparing base (a8c8c18) to head (71e7a57).

Files with missing lines Patch % Lines
...n/java/com/cloud/vm/VirtualMachineManagerImpl.java 40.00% 30 Missing and 3 partials ⚠️
...com/cloud/vm/VirtualMachinePowerStateSyncImpl.java 94.87% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               4.22   #14207      +/-   ##
============================================
+ Coverage     17.97%   18.00%   +0.03%     
- Complexity    16184    16219      +35     
============================================
  Files          5930     5930              
  Lines        535615   535695      +80     
  Branches      65582    65594      +12     
============================================
+ Hits          96271    96465     +194     
+ Misses       428377   428241     -136     
- Partials      10967    10989      +22     
Flag Coverage Δ
uitests 4.02% <ø> (ø)
unittests 19.08% <62.76%> (+0.03%) ⬆️

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.

The two helpers added earlier in this branch called `new StopCommand(...)`
directly. `sendStop()` does more than that, and skipping it has consequences:

| not set                      | effect                                                        |
|------------------------------|---------------------------------------------------------------|
| `vlanToPersistenceMap`       | `shouldDeleteBridge()` returns true for an empty map, so the host deletes bridges belonging to persistent networks |
| external hypervisor details  | `ExternalPathPayloadProvisioner` dereferences `cmd.getVirtualMachine()`, which is null -> NPE |
| `controlIp`                  | the cmdline backup for system VMs does not happen             |
| `volumesToDisconnect`        | volumes stay connected on the host                            |

Fix: pull the command construction out of `sendStop()` into
`buildStopCommand()` and use it in both helpers, so there is one place that
knows how to build a StopCommand. `sendStop()` behaviour is unchanged.

External instances are skipped in both helpers. Their teardown is done by
their extension in `finalizeExpunge`, and a StopCommand issued from here
would not carry what that path needs.

Also removes a duplicated Javadoc block, which left
`stopUnmanagedInstanceOnReportingHost` documented as the other method.

Signed-off-by: Brad House <bhouse@nexthop.ai>
The tests added earlier stubbed `findByHostInStatesExcluding` with a single
`Mockito.any()` for a varargs parameter that receives three states:

    findByHostInStatesExcluding(Long hostId, Collection<Long> excludingIds, State... states)

The matcher never matched, so the mock returned its default empty list,
`processMissingVmReport()` returned at the `isEmpty()` check, and the
`never()` assertions passed without exercising anything.

Fix: match the varargs explicitly. Verified by mutation - disabling the
`hasRecentStateChange()` guard now fails
`test_processMissingVmReport_forcedReportDoesNotOverrideRecentStateChange`
with NeverWantedButInvoked. It did not before.

Tests now cover:

| test                                          | checks                                           |
|-----------------------------------------------|--------------------------------------------------|
| forcedReportDoesNotOverrideRecentStateChange  | a forced report does not mark a just-changed instance missing |
| forcedReportStillReportsSettledInstance       | a settled instance is still reported, so out-of-band stop detection works |
| unforcedReportHonoursGracefulPeriod           | the graceful period still applies without force  |
| recordsUnknownInstances                       | an unknown name is recorded, not just logged     |

Also moves the "Detected missing VM" line below the guard, so a skipped
instance is no longer logged as detected.

Signed-off-by: Brad House <bhouse@nexthop.ai>
@bhouse-nexthop

Copy link
Copy Markdown
Collaborator Author

Self-review follow-up. Two defects found in the new stop paths, both fixed.

1. The new StopCommands were built by hand and missed what sendStop() sets.

sendStop() enriches the command with the external hypervisor details, the VLAN persistence map, the control NIC address and the volumes to disconnect. The two new helpers called new StopCommand(...) directly and set none of it. Consequences:

omission effect
vlanToPersistenceMap LibvirtComputingResource.shouldDeleteBridge() returns true for an empty map, so the host deletes bridges belonging to persistent networks
external hypervisor details ExternalPathPayloadProvisioner dereferences cmd.getVirtualMachine(), which would be null -> NPE on every External expunge
controlIp the cmdline backup for system VMs silently does not happen
volumesToDisconnect volumes left connected on the host

Fixed by extracting buildStopCommand() out of sendStop() and using it in both helpers, so there is one place that knows how to build a StopCommand. External instances are now skipped in both helpers: their teardown is done by the extension in finalizeExpunge.

2. The new tests did not test the fix.

The processMissingVmReport stub used a single Mockito.any() for a varargs parameter that receives three states, so it never matched. findByHostInStatesExcluding returned Mockito's default empty list, the method returned early, and the never() assertions passed without exercising anything.

Fixed by matching the varargs explicitly. Verified by mutation: disabling the hasRecentStateChange guard now makes test_processMissingVmReport_forcedReportDoesNotOverrideRecentStateChange fail, which is the point of the test.

Also in this push:

  • the "Detected missing VM" debug line was logged before the skip; moved below it
  • removed a duplicated Javadoc block left by a rebase

Known limitations, deliberately not addressed here:

  • The guard assumes an in-flight report is at most 2 x ping.interval stale. If management-server queueing delays processing beyond that, the race can still occur. The robust fix is a collection timestamp on PingRoutingCommand, which is a larger change and better done separately.
  • For an out-of-band migration, a successful StopAnswer from the old host proves the domain is not there, not that the instance is gone. Not a regression, the previous code released resources unconditionally.

`sendStop()` swallows `AgentUnavailableException` and `OperationTimedoutException`
and reports success when `force` is true:

    } catch (final AgentUnavailableException | OperationTimedoutException e) {
        if (!force) { return new Pair<>(false, errorMsg); }
    }
    return new Pair<>(true, null);

So a host that is unreachable or too slow to answer counts as a successful
stop, and the caller goes on to release the NICs and IP addresses. A host too
busy to answer is exactly the condition that produces the stale report this
branch is reacting to, so that is the worst case to guess in.

| report              | force | on an unreachable host        |
|---------------------|-------|-------------------------------|
| PowerOff            | true  | unchanged, host said it is down |
| PowerReportMissing  | false | back off, keep the addresses, let a later report decide |

A PowerOff report is the host stating the instance is down. A missing report
only means the host did not list it, which is not the same thing.

Also makes `handlePowerOffReportWithNoPendingJobsOnVM()` protected so this
branch can be tested.

Signed-off-by: Brad House <bhouse@nexthop.ai>
…about

A warn line only helps someone already reading the log. Raise an
ALERT_TYPE_SYNC alert as well, so the condition reaches whoever watches
alerts.

The alert follows the same rule as the log: raised when the set of unknown
instances on a host changes, not on every report.

Nothing is stopped automatically. An unknown instance may be a domain an
operator put on the host deliberately.

Signed-off-by: Brad House <bhouse@nexthop.ai>
These are the branches that decide whether a still-running instance keeps its
addresses, and they had no tests.

`VirtualMachineManagerImplTest`:

| test                                          | checks                                        |
|-----------------------------------------------|-----------------------------------------------|
| missingReportUsesUnforcedStopAndKeepsResources | a missing report stops unforced, and a failed stop does not release resources |
| powerOffKeepsForcedStop                        | a PowerOff report still stops forced          |
| ensure...skipsExternal                         | External instances are left to their extension |
| ensure...noHostIdDoesNothing                   | nothing is sent when there is no host to send to |
| ensure...fallsBackToLastHostId                 | the last known host is used when `host_id` is cleared |
| ensure...agentUnavailableIsSwallowed           | an unreachable host does not break the expunge |

`VirtualMachinePowerStateSyncImplTest` gains the AlertManager mock the alert
needs.

Signed-off-by: Brad House <bhouse@nexthop.ai>
@bhouse-nexthop

bhouse-nexthop commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

Three further changes pushed.

1. The stop for a missing report was forced. sendStop() swallows the agent exceptions and answers success when forced:

} catch (final AgentUnavailableException | OperationTimedoutException e) {
    if (!force) { return new Pair<>(false, errorMsg); }
}
return new Pair<>(true, null);

So an unreachable or slow host counted as a successful stop and the addresses were released. A host too busy to answer is the condition that produces the stale report in the first place.

report force unreachable host
PowerOff true unchanged, the host said it is down
PowerReportMissing false back off, keep the addresses

2. Unknown instances now raise an ALERT_TYPE_SYNC alert, not only a log line, on the same once-per-change rule. Nothing is stopped automatically.

3. Tests for the branches that decide whether an address is freed: the PowerReportMissing path (unforced stop, failed stop does not release resources) and ensureInstanceIsStoppedOnLastKnownHost (External skipped, no host id, last_host_id fallback, agent unavailable).

Not changed: aborting the expunge when the stop fails. UserVmManagerImpl.expunge() calls _networkMgr.release() before advanceExpunge(), so the addresses are already released by then, and a failed expunge sends the instance to Error via transitionExpungingToError(), where the expunge sweep does not pick it up. That would be worse than the current behaviour. The ordering in UserVmManagerImpl is the real fix and belongs in its own change; it is listed under Known limitations in the description, along with the report-collection-timestamp follow-up.

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.

1 participant