sb-abhish3k opened a new pull request, #14016:
URL: https://github.com/apache/cloudstack/pull/14016

   ### Description
   
   The `op_host_capacity` table experiences severe lock contention during 
concurrent VM lifecycle operations. Slow query analysis shows:
   
   - **25s average execution time** (99% lock wait, not query execution)
   - **5,724 executions in 20 hours** across 3 management server nodes
   - **3% failure rate** from `errno 1205` (InnoDB lock wait timeout exceeded)
   - Query: `SELECT ... FROM op_host_capacity WHERE id = ? FOR UPDATE`
   
   **Root cause:** `CapacityManagerImpl.releaseVmCapacity()` and 
`allocateVmCapacity()` use a lock-read-compute-write pattern via 
`GenericDaoBase.lockRow()`. Each VM operation acquires exclusive row locks on 3 
capacity rows (CPU, Memory, CPU Core), then holds those locks while performing 
Java computation, querying `cluster_details` for overcommit ratios, logging, 
and validating — before finally committing. Any concurrent VM operation 
targeting the same host queues behind this entire sequence.
   
   **Fix:** Replace the `lockRow()` + `Transaction.execute()` pattern with 
single-statement atomic SQL UPDATEs that push arithmetic to the database:
   
   ```sql
   -- Example: decrement used capacity (was lockRow + Java subtract + update)
   UPDATE op_host_capacity SET
     used_capacity = CASE WHEN used_capacity >= ? THEN used_capacity - ? ELSE 
used_capacity END,
     update_time = NOW()
   WHERE id = ?
   ```
   
   Six new atomic DAO methods cover all capacity mutation patterns:
   - `decrementUsedCapacity` — VM stop/migrate away
   - `decrementReservedCapacity` — release reserved (destroy/expunge)
   - `incrementUsedCapacity` — VM start/migrate to
   - `decrementUsedIncrementReservedCapacity(id, used, reserved, 
overcommitRatio)` — VM stop with reservation (capped at overcommitted total)
   - `decrementUsedIncrementReservedCapacity(id, used, reserved)` — same, 
uncapped (for CPU core which has no overcommit)
   - `incrementUsedDecrementReservedCapacity` — allocate from last host
   
   **Lock duration reduction:** From seconds (full transaction span including 
cluster_details reads, Java math, logging) to microseconds (single UPDATE 
statement). InnoDB still acquires an implicit row lock for each UPDATE, but 
releases it immediately on statement completion. 
   
   **Behavioral changes from original code:**
   
   | Aspect | Before | After |
   |---|---|---|
   | Lock scope | 3 rows locked simultaneously in one transaction | 1 row at a 
time, independent autocommit |
   | Negative capacity guard | Java `if (used >= amount)` — leaves value 
unchanged | SQL `CASE WHEN used >= ? THEN used - ? ELSE used END` — same 
semantics |
   | `fromLastHost` reserved decrement | Cross-row check: only decrements all 3 
if CPU AND Memory both have enough reserved | Per-row: each independently 
decrements with `GREATEST(reserved - ?, 0)` floor. Fixes a reserved capacity 
leak in the original where one insufficient resource blocked all three from 
being freed |
   | Capacity validation in `allocateVmCapacity` | Validated inside lock after 
incrementing (rollback on failure) | Validated before atomic increment (same 
pre-update DB state check). Tiny race window — acceptable since capacity 
accounting is approximate and `updateCapacityForHost()` periodically 
recalibrates |
   | Overcommit ratio reads | Inside lock (adds lock hold time) | Before update 
(no lock contention contribution) |
   
   **Atomicity guarantees:**
   
   The old code relied on explicit `SELECT ... FOR UPDATE` row locks held 
across a multi-statement transaction to ensure correctness. The new code relies 
on InnoDB's implicit row-level locking within single UPDATE statements. Both 
are correct, but the lock hold time differs by orders of magnitude.
   
   **Before — explicit transaction locking:**
   ```
   BEGIN
     SELECT ... FOR UPDATE  ← X lock acquired on row (blocks here if contended)
     -- lock held --
     Java: read used/reserved/total from locked row
     Java: query cluster_details table for overcommit ratios (extra DB 
round-trip while holding lock)
     Java: compute new capacity values
     Java: log debug statements
     Java: validate capacity
     UPDATE row 1 (CPU)
     UPDATE row 2 (Memory)
     UPDATE row 3 (CPU Core)
   COMMIT                   ← X locks on all 3 rows released
   ```
   Lock hold time: **seconds** (measured avg 25s in production). Three rows 
locked simultaneously for the entire transaction span. Concurrent VM ops on the 
same host queue behind this.
   
   **After — single-statement atomic updates:**
   ```
   -- All reads and computation done BEFORE any locks --
   Java: query capacity rows (regular SELECT, no lock)
   Java: query cluster_details for overcommit ratios
   Java: validate capacity
   
   UPDATE row 1 (CPU)      ← X lock acquired, UPDATE executes, autocommit 
releases lock
   UPDATE row 2 (Memory)   ← X lock acquired, UPDATE executes, autocommit 
releases lock
   UPDATE row 3 (CPU Core) ← X lock acquired, UPDATE executes, autocommit 
releases lock
   ```
   Lock hold time per row: **microseconds** (single UPDATE statement). Each row 
locked independently for the minimum possible duration.
   
   **How InnoDB guarantees correctness for concurrent updates:**
   
   When two sessions concurrently issue `UPDATE op_host_capacity SET 
used_capacity = used_capacity + ? WHERE id = ?` on the same row:
   
   1. Session A reaches the row first, acquires an exclusive (X) lock on the 
clustered index entry
   2. Session B attempts to acquire the same X lock, enters InnoDB's lock wait 
queue
   3. Session A's UPDATE completes and autocommits — X lock is released
   4. Session B is granted the lock and reads the **latest committed value** of 
`used_capacity` (post-Session-A), then applies its own increment
   5. Session B's UPDATE completes and autocommits
   
   This is guaranteed by InnoDB's locking protocol: an UPDATE always reads the 
latest committed version of the row, not an MVCC snapshot. Both increments are 
correctly applied with no lost updates. This behavior is identical in MySQL 
(5.6+) and MariaDB (10.x+), which both use InnoDB as the default storage 
engine. All SQL constructs used (`GREATEST`, `CASE WHEN`, `CAST ... AS SIGNED`, 
`NOW()`) are supported since MySQL 4.0+.
   
   
   **What changes and what is acceptable:**
   
   1. **Single-row correctness (no change):** Each UPDATE is atomic and 
serialized by InnoDB's row lock. `used_capacity = used_capacity + ?` cannot 
lose updates, and `CASE WHEN used_capacity >= ? THEN used_capacity - ? ELSE 
used_capacity END` prevents negative values. Equivalent to the old Java guards 
(`if (usedCpu >= vmCPU)`).
   
   2. **Cross-row atomicity (relaxed, acceptable):** The old code updated CPU, 
Memory, and CPU Core in a single transaction — all-or-nothing. The new code 
uses three independent autocommit statements. If a DB connection dies between 
UPDATE 1 and UPDATE 2, capacity state is temporarily inconsistent for that 
host. This is an extremely unlikely failure mode (requires connection loss 
between two statements milliseconds apart), and `updateCapacityForHost()` 
periodic recalibration self-heals any inconsistency by recomputing capacity 
from actual VM state.
   
   3. **Capacity validation window (slightly wider, acceptable):** In 
`allocateVmCapacity`, the capacity check (`checkIfHostHasCapacity`) now runs 
before the atomic UPDATE rather than inside the locked transaction. Two VMs 
could both pass validation before either writes. However, this is the same 
semantic as the original code — the original also validated against pre-update 
DB state (the `checkIfHostHasCapacity` call performs its own `findByHostIdType` 
SELECT, which reads uncommitted-to-disk values since the 
`_capacityDao.update()` hasn't been called yet within the transaction). The 
validation window is slightly wider without locks, but capacity accounting is 
inherently approximate and `updateCapacityForHost()` recalibration is the 
safety net.
   
   4. **`fromLastHost` reserved decrement (improved):** The old code had a 
cross-row invariant (`reservedCpu >= cpu && reservedMem >= ram`) gating all 
three decrements — if memory didn't have enough reserved, CPU reserved wasn't 
freed either, leaking reserved capacity until the next recalibration. The new 
per-row `GREATEST(reserved - ?, 0)` frees each resource independently. This is 
strictly more correct.
   
   ### 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
   
   #### Feature/Enhancement Scale
   
   - [ ] Major
   - [ ] Minor
   
   #### Bug Severity
   
   - [ ] BLOCKER
   - [ ] Critical
   - [ ] Major
   - [ ] Minor
   - [ ] Trivial
   
   ### Screenshots (if appropriate):
   
   ### How Has This Been Tested?
   
   <!-- Please describe in detail how you tested your changes. -->
   <!-- Include details of your testing environment, and the tests you ran to 
-->
   
   #### How did you try to break this feature and the system with this change?
   
   9 new unit tests added to `CapacityManagerImplTest` (15 total, all pass):
   
   - **`testReleaseVmCapacityNullHostReturnsTrue`** — null host guard clause
   - **`testReleaseVmCapacityNullCapacityReturnsFalse`** — null capacity entry 
guard clause
   - **`testReleaseVmCapacityDecrementUsed`** — verifies 
`decrementUsedCapacity` called for CPU/Memory/CPU Core with correct amounts 
when `moveFromReserved=false, moveToReserved=false`
   - **`testReleaseVmCapacityDecrementUsedIncrementReserved`** — verifies 
capped variant called with overcommit ratio for CPU/Memory, uncapped variant 
for CPU Core when `moveToReserved=true`
   - **`testReleaseVmCapacityDecrementReserved`** — verifies 
`decrementReservedCapacity` called when `moveFromReserved=true`
   - **`testAllocateVmCapacityNewHost`** — verifies `incrementUsedCapacity` 
called for all three capacity types
   - **`testAllocateVmCapacityFromLastHost`** — verifies 
`incrementUsedDecrementReservedCapacity` called for all three capacity types
   - **`testAllocateVmCapacityInsufficientThrows`** — verifies 
`CloudRuntimeException` thrown when host lacks capacity
   - **`testAllocateVmCapacityNullCapacityReturnsEarly`** — verifies no DAO 
mutation when capacity entries are null
   
   All tests verify `lockRow` is **never** called, confirming the lock-free 
atomic path.
   
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to