docs: clarify storage type-code matching in stock control

The stock-control safety bullets and algorithm description called
`primaryStorageProfile` a "profile id" matched against `otherStorage[].id`.
That mirrored the buggy implementation rather than the actual VirtFusion
contract: it's a storage *type code* (mirrors `server_packages.storage_type`)
that filters against `otherStorage[].storageType`. Updated CLAUDE.md and
README.md so future readers don't repeat the bug. Also documents the new
behavior of walking all matching pools and picking the largest fit, with
disabled peers skipped rather than treated as fatal.

The `storageProfile` mapping table row is intentionally left untouched —
that documents an admin-facing configurable-option alias, and renaming it
could quietly invalidate existing operator setups.
This commit is contained in:
Prophet731
2026-04-26 02:21:45 -04:00
parent e9772ed29f
commit 02e059274b
2 changed files with 5 additions and 5 deletions

View File

@@ -141,7 +141,7 @@ Opt-in per product via WHMCS's native stock-control toggle (`tblproducts.stockco
**Data sources (authoritative):** **Data sources (authoritative):**
- `GET /packages/{id}` — per-VPS resource footprint (`memory`, `cpuCores`, `primaryStorage`, `primaryStorageProfile`, `enabled`) - `GET /packages/{id}` — per-VPS resource footprint (`memory`, `cpuCores`, `primaryStorage`, `primaryStorageProfile`, `enabled`)
- `GET /compute/hypervisors/groups/{id}/resources` — live free/allocated per hypervisor with per-metric quotas, storage pools (matched by package.primaryStorageProfile), and a group-level IPv4 pool - `GET /compute/hypervisors/groups/{id}/resources` — live free/allocated per hypervisor with per-metric quotas, storage pools (filtered by `pool.storageType` against the package's `primaryStorageProfile` *type code* — see Safety properties), and a group-level IPv4 pool
**Algorithm:** for every group the product can be placed in (default `configoption1` plus every numeric value of the `Location` configurable option), sum `min(memory, cpu, storage)` across eligible hypervisors (enabled AND commissioned AND !prohibit) and cap by the group-level IPv4 pool (`max` across hypervisors, not summed — IPv4 is a single group-wide pool). Sum across groups → qty. **Algorithm:** for every group the product can be placed in (default `configoption1` plus every numeric value of the `Location` configurable option), sum `min(memory, cpu, storage)` across eligible hypervisors (enabled AND commissioned AND !prohibit) and cap by the group-level IPv4 pool (`max` across hypervisors, not summed — IPv4 is a single group-wide pool). Sum across groups → qty.
@@ -160,7 +160,7 @@ Opt-in per product via WHMCS's native stock-control toggle (`tblproducts.stockco
- Transient API failures (null from `fetchPackage` / `fetchGroupResources`) leave `qty` UNTOUCHED — never silently takes the catalogue offline. - Transient API failures (null from `fetchPackage` / `fetchGroupResources`) leave `qty` UNTOUCHED — never silently takes the catalogue offline.
- Confirmed-missing conditions (HTTP 404 on package, `package.enabled=false`) return qty=0 — the product genuinely cannot be provisioned. - Confirmed-missing conditions (HTTP 404 on package, `package.enabled=false`) return qty=0 — the product genuinely cannot be provisioned.
- IPv4 cap is max-within-group (not summed across hypervisors) to avoid double-counting the shared pool. - IPv4 cap is max-within-group (not summed across hypervisors) to avoid double-counting the shared pool.
- Storage match is strict: the package's `primaryStorageProfile` must exist and be enabled on the target hypervisor, otherwise that hypervisor contributes 0. Falls back to `localStorage` only when the package has no profile set. - Storage matching uses the package's `primaryStorageProfile` as a **storage type code** (it mirrors VirtFusion's `server_packages.storage_type` column — a *filter*, not a pool id). The hypervisor must expose at least one `otherStorage[]` pool whose `storageType` equals that code; if multiple match (e.g. several mountpoint pools on the same hypervisor) the one that fits the most VMs wins. A disabled pool is skipped, not fatal — an enabled peer of the same type still contributes. Hypervisors with no pool of the matching type contribute 0. Falls back to `localStorage` only when the package has no profile set (`primaryStorageProfile <= 0`).
- Stock control is gated by `tblproducts.stockcontrol=1` per product — the module never touches qty on products that opt out. - Stock control is gated by `tblproducts.stockcontrol=1` per product — the module never touches qty on products that opt out.
**Per-product setting:** `stockSafetyBufferPct` (configoption7, default 10). Reserves X% of each resource's `max` before computing fits; ignored for unlimited resources (`max=0`) and for IPv4 (no per-hypervisor `max` in the response). Admins can override per product in the module settings; blank falls back to 10%. **Per-product setting:** `stockSafetyBufferPct` (configoption7, default 10). Reserves X% of each resource's `max` before computing fits; ignored for unlimited resources (`max=0`) and for IPv4 (no per-hypervisor `max` in the response). Admins can override per product in the module settings; blank falls back to 10%.

View File

@@ -91,7 +91,7 @@ You also need a VirtFusion API token with the following permissions:
### Stock Control (Dynamic Inventory) ### Stock Control (Dynamic Inventory)
- **Out-of-stock badges driven by real hypervisor capacity** — opt-in per product via WHMCS's native Stock Control toggle. When enabled, the module keeps `tblproducts.qty` synced to the number of VPSes the panel can still actually provision, and WHMCS renders the "Out of Stock" badge, disables Add-to-Cart, and refuses checkout natively. No templates or JavaScript required. - **Out-of-stock badges driven by real hypervisor capacity** — opt-in per product via WHMCS's native Stock Control toggle. When enabled, the module keeps `tblproducts.qty` synced to the number of VPSes the panel can still actually provision, and WHMCS renders the "Out of Stock" badge, disables Add-to-Cart, and refuses checkout natively. No templates or JavaScript required.
- **Live-capacity math** — combines `/packages/{id}` (per-VPS resource footprint) with `/compute/hypervisors/groups/{id}/resources` (live per-hypervisor free/allocated) to compute qty across every group the product can be placed in. Strict storage-profile matching; group-level IPv4 pool accounted for without double-counting. - **Live-capacity math** — combines `/packages/{id}` (per-VPS resource footprint) with `/compute/hypervisors/groups/{id}/resources` (live per-hypervisor free/allocated) to compute qty across every group the product can be placed in. Storage matching is by **type code** (`pool.storageType`), so a package targeting e.g. mountpoint storage qualifies on every hypervisor that exposes a mountpoint pool — and picks the largest-fit pool when several share the same type. Group-level IPv4 pool accounted for without double-counting.
- **Event-driven refresh** — qty recalculates after every successful provision (`AfterModuleCreate`), termination (`AfterModuleTerminate`), and on cart/order page views for individual products. A 2-hour safety-net cron catches capacity changes made directly in the VirtFusion panel. - **Event-driven refresh** — qty recalculates after every successful provision (`AfterModuleCreate`), termination (`AfterModuleTerminate`), and on cart/order page views for individual products. A 2-hour safety-net cron catches capacity changes made directly in the VirtFusion panel.
- **Per-product safety buffer** — `stockSafetyBufferPct` config option (default 10%) reserves headroom so the storefront stops selling before a hypervisor is literally at 100%. - **Per-product safety buffer** — `stockSafetyBufferPct` config option (default 10%) reserves headroom so the storefront stops selling before a hypervisor is literally at 100%.
- **Fail-safe under API outages** — transient VirtFusion API failures leave `qty` UNCHANGED instead of zeroing it, so a brief network blip doesn't take the catalogue offline. - **Fail-safe under API outages** — transient VirtFusion API failures leave `qty` UNCHANGED instead of zeroing it, so a brief network blip doesn't take the catalogue offline.
@@ -263,7 +263,7 @@ For every stock-controlled VirtFusion product:
1. Resolve the set of hypervisor groups the product can be placed in — the default group (Config Option 1) plus every numeric value of the `Location` configurable option if one is attached. 1. Resolve the set of hypervisor groups the product can be placed in — the default group (Config Option 1) plus every numeric value of the `Location` configurable option if one is attached.
2. Fetch the product's package via `GET /packages/{id}` for the per-VPS resource footprint (`memory`, `cpuCores`, `primaryStorage`, `primaryStorageProfile`). 2. Fetch the product's package via `GET /packages/{id}` for the per-VPS resource footprint (`memory`, `cpuCores`, `primaryStorage`, `primaryStorageProfile`).
3. For each eligible group, fetch live resources via `GET /compute/hypervisors/groups/{id}/resources`. 3. For each eligible group, fetch live resources via `GET /compute/hypervisors/groups/{id}/resources`.
4. For each hypervisor in the group that passes eligibility (`enabled` AND `commissioned` AND `!prohibit`), compute `min(memory, cpu, storage)` fits — with the per-product buffer applied — against the matched storage pool (strict match on `package.primaryStorageProfile` against `otherStorage[].id`; falls back to `localStorage` only when the package has no profile set). 4. For each hypervisor in the group that passes eligibility (`enabled` AND `commissioned` AND `!prohibit`), compute `min(memory, cpu, storage)` fits — with the per-product buffer applied — against the matched storage pool. `package.primaryStorageProfile` is a **storage type code** (mirrors VirtFusion's `server_packages.storage_type` column — a *filter*, not a pool id), matched against each `otherStorage[].storageType`. If multiple pools on the same hypervisor share that type (e.g. several mountpoint pools), the one with the largest fit wins; disabled peers are skipped, not fatal. Falls back to `localStorage` only when the package has no profile set.
5. Sum across hypervisors in each group, cap by the group-level IPv4 pool (`max()` within a group to avoid double-counting the shared pool), then sum across groups → `qty`. 5. Sum across hypervisors in each group, cap by the group-level IPv4 pool (`max()` within a group to avoid double-counting the shared pool), then sum across groups → `qty`.
**Refresh triggers:** **Refresh triggers:**
@@ -279,7 +279,7 @@ For every stock-controlled VirtFusion product:
**Safety properties:** **Safety properties:**
- **Transient API failures leave `qty` UNCHANGED.** `Module::fetchPackage()` and `Module::fetchGroupResources()` return a tri-state `array | false | null`: `false` means "VirtFusion confirmed this doesn't exist → OOS is correct", `null` means "we can't tell right now → don't touch existing qty". Without this distinction the module would either zero out inventory during API blips or show inventory for deleted packages. - **Transient API failures leave `qty` UNCHANGED.** `Module::fetchPackage()` and `Module::fetchGroupResources()` return a tri-state `array | false | null`: `false` means "VirtFusion confirmed this doesn't exist → OOS is correct", `null` means "we can't tell right now → don't touch existing qty". Without this distinction the module would either zero out inventory during API blips or show inventory for deleted packages.
- **Confirmed-missing → qty=0.** HTTP 404 on the package or `package.enabled=false` forces qty=0, because the product genuinely cannot be provisioned. - **Confirmed-missing → qty=0.** HTTP 404 on the package or `package.enabled=false` forces qty=0, because the product genuinely cannot be provisioned.
- **Storage profile mismatch → 0 for that hypervisor.** If the package targets `primaryStorageProfile=4` but the hypervisor only exposes profiles 23/28, that hypervisor contributes zero capacity — not a guess at "maybe placement will work out." - **Storage type mismatch → 0 for that hypervisor.** If the package targets storage type code `4` (mountpoint) but the hypervisor only exposes pools of type `0` (local default), that hypervisor contributes zero capacity — not a guess at "maybe placement will work out." This is a filter on `pool.storageType`, not on `pool.id`; identical type codes across different hypervisors all qualify, which is what makes multi-hypervisor mountpoint/datastore placement work.
- **Stock Control gate is absolute.** Products without `tblproducts.stockcontrol=1` are never touched, even by the cron safety net. - **Stock Control gate is absolute.** Products without `tblproducts.stockcontrol=1` are never touched, even by the cron safety net.
- **`\Throwable` catches** on every stock-path entry point (not just `\Exception`) so a `TypeError` from a malformed API response can't escape the tri-state contract. - **`\Throwable` catches** on every stock-path entry point (not just `\Exception`) so a `TypeError` from a malformed API response can't escape the tri-state contract.