Skip to content

Fix duplicate public IP allocated to system VMs at zone startup - #14348

Open
nagaboinaramgopal wants to merge 3 commits into
apache:mainfrom
nagaboinaramgopal:pr/fix-sysvm-public-ip-race
Open

nagaboinaramgopal wants to merge 3 commits into
apache:mainfrom
nagaboinaramgopal:pr/fix-sysvm-public-ip-race

Conversation

@nagaboinaramgopal

Copy link
Copy Markdown
Contributor

Description

At zone bring-up the console proxy and secondary storage VMs are brought up by two independent
capacity scanner threads. Both resolve their public NIC through the same path
(PublicNetworkGuru.getIp with forSystemVms, then IpAddressManagerImpl.fetchNewPublicIp), and on
a fresh zone they can hit it within a few hundred milliseconds of each other.
assignIpAddressWithLock guarded the Free to Allocating transition with an op_lock application
lock and a plain, non-locking read, and released that lock before the surrounding transaction
committed. In that window the second thread took the lock, still read the row as Free from its
own snapshot, and allocated the same pool row. The result was two nics on the public network
with the same address, an ARP conflict, and VM consoles stuck on "Connecting to VM console"
because the console proxy and the SSVM were fighting over one IP.

The allocation UPDATEs an existing pool row rather than inserting, so the unique key on
user_ip_address never fires, and nics has no unique index on (network_id, ip4_address), so
nothing at the DB layer refused it either.

The fix re-reads the candidate row under a FOR UPDATE row lock held for the whole allocation
transaction and re-checks the state under that lock

IPAddressVO userIp = _ipAddressDao.lockRow(possibleAddr.getId(), true);

so a second thread blocks until the first commits, then sees the row is no longer Free and backs
off. This is the same row lock the markPublicIpAsAllocated step right after it already relies on.
#9234 locked the user facing public IP APIs but not this system VM allocation path.

On a lost race the losing scanner gets no free address for that attempt and retries on its next
scan cycle rather than allocating a duplicate, so it is self healing with no duplicate and no
manual recovery.

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

  • Major

How Has This Been Tested?

Added IpAddressManagerImplTest for assignIpAddressWithLock: it allocates when the locked read is
Free, backs off and does not update when the locked read comes back Allocating (the losing
thread), and returns null when the row is gone. The tests assert the read goes through
lockRow(id, true), the FOR UPDATE read the fix adds, so they fail before the change (the method
used acquireInLockTable with a plain read) and pass after. server module build and the new tests
are green.

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

assignIpAddressWithLock has a single caller and always runs inside the assignAndAllocateIpAddressEntry
transaction, so the FOR UPDATE lock is held to commit rather than per statement. One row is locked
per transaction, so there is no lock ordering deadlock. The sub-second allocation race is timing
dependent and not deterministically reproducible, so the guarantee rests on the FOR UPDATE row lock
being held to commit, the same mechanism the existing markPublicIpAsAllocated step uses.

assignIpAddressWithLock guarded the Free to Allocating transition with an
op_lock application lock plus a plain, non-locking read, and released that
lock before the enclosing transaction committed. Two system VM scanner
threads (console proxy and secondary storage) starting at zone bring-up
could both read the same pool row as Free and both allocate it, leaving
two nics with the same public IP and a VM console that never connects.

Re-read the candidate row with a FOR UPDATE row lock held for the whole
allocation transaction and re-check the state under it. A second thread
now blocks until the first commits and then sees the row is no longer
Free, so it backs off instead of allocating the same address. This is the
same row lock the following markPublicIpAsAllocated step already relies on.
@codecov

codecov Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.33333% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 19.90%. Comparing base (ed1db53) to head (f3b4181).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
...n/java/com/cloud/network/IpAddressManagerImpl.java 83.33% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main   #14348      +/-   ##
============================================
- Coverage     19.91%   19.90%   -0.01%     
+ Complexity    20200    20197       -3     
============================================
  Files          6373     6373              
  Lines        577230   577230              
  Branches      70696    70696              
============================================
- Hits         114958   114926      -32     
- Misses       449703   449740      +37     
+ Partials      12569    12564       -5     
Flag Coverage Δ
uitests 3.71% <ø> (ø)
unittests 21.18% <83.33%> (-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.

@nagaboinaramgopal

Copy link
Copy Markdown
Contributor Author

@Damans227 Could you pls take a look

logger.debug("locked row for ip address {} (id: {})", possibleAddr.getAddress(), possibleAddr.getUuid());
if (userIp.getState() != State.Free) {
logger.debug("locked ip address {} is not free {}", possibleAddr.getAddress(), userIp.getState());
return null;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the list passed in here only has one ip, so does the losing system vm now fail with a no free ip error even when other ips are free? could we try the next free ip instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 85aba61. assignAndAllocateIpAddressEntry now returns null instead of failing when its candidate was taken concurrently, and fetchNewPublicIp re-selects a different free IP and retries (bounded to a few attempts). listAvailablePublicIps filters on the Free state, so the retry skips the address the winner already moved to Allocating and picks another; the no-free-IP error is now only raised once the pool is genuinely exhausted.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that covers it, thanks

assignAndAllocateIpAddressEntry failed by throwing when its single candidate
was locked and found no longer Free (taken by a concurrent allocation), so the
losing system VM reported no free IP even when other addresses were free.
Return null in that case and have fetchNewPublicIp re-select a different free IP
and retry, bounded to a few attempts. listAvailablePublicIps filters on the Free
state, so the retry skips the address the winner already moved to Allocating and
picks another; InsufficientAddressCapacityException is thrown only once the
attempts are exhausted.
// and re-checks it is still Free, so the loser gets back null; re-select a different free IP and retry
// rather than failing with no-free-IP while free addresses still exist.
IPAddressVO addr = null;
for (int attempt = 1; attempt <= MAX_PUBLIC_IP_ALLOCATION_ATTEMPTS; attempt++) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we add a test where the first pick is taken and the second try gets another ip? the new tests only cover the lock part

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 43384a5: testFetchNewPublicIpRetriesWhenTheFirstPickIsTakenConcurrently drives the first candidate to be taken (assignAndAllocateIpAddressEntry returns null) and asserts fetchNewPublicIp re-selects and allocates a different free IP instead of failing, verifying it attempts allocation twice. 4 tests pass in IpAddressManagerImplTest.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that covers it, thanks

@Damans227

Copy link
Copy Markdown
Collaborator

thanks @nagaboinaramgopal, fix looks right. LGTM.

Add a unit test proving fetchNewPublicIp re-selects a different free IP when
the first candidate is taken by a concurrent allocation, instead of failing
with no-free-IP. Extract the final PublicIp build into buildPublicIp and make
assignAndAllocateIpAddressEntry package-visible so the retry loop can be
exercised without a live database.

This branch has not been deployed

No deployments
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.

2 participants