Files
werkator/docs/prs/2026-10-05-PR#123-sizing-advice-split.md
miandmhoennig c843e9a42a
werkator build succeeded after 04:44
werkator/deploy build succeeded after 00:08
RAM and disk sizing advice split into their steps, every advice chip tested (v1.37.4) (#123)
> Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.

## The Problem

Four methods of the sizing advice are above the Kotlin target of 15, measured by the nightly mutation build of 2026-10-05:

| Method                  | Mutation-CRAP | Complexity | Mutation score |
|-------------------------|---------------|------------|----------------|
| `CapacitySizing.ram`    | 26.87         | 15         | 0.63           |
| `CapacitySizing.column` | 26.83         | 6          | 0.17           |
| `CapacitySizing.disk`   | 21.19         | 16         | 0.73           |
| `UiFormats.adviceLabel` | 15.55         | 5          | 0.25           |

`ram` and `disk` decide every verdict of their resource in one method each, as `cpu` did before PR#113.
`column` and `adviceLabel` are short, but their tests never told the alternatives apart: every test tier carried the same values as averages and as maxima, and no test asked for a `watch` chip.

## Non-Goals

- No change in behaviour: every verdict, size and reason stays as it was.
- The other groups above 15 follow in their own PRs.

## The Scenarios

### Feature: the sizing reads the right column and words every verdict

#### Scenario#123.01: RAM is sized from the quarter-hour maxima where the tier has them, else from the averages

So that a moment of overflow, which is what RAM is sized against, is never averaged away, and a tier without maxima still gets advice.

- **Given** ten days of history whose RAM averages stay at 2.0 GiB
- **When** the maxima hold one quarter hour at 4.4 GiB
- **Then** the RAM peak is 4.4 GiB
- **When** the tier has no maxima for RAM, or an empty column
- **Then** the RAM peak is 2.0 GiB, from the averages
- **When** the maxima hold only gaps
- **Then** there is no peak at all, and a gap never counts as zero

##### Verified by

- [CapacitySizingUnitTest](../../src/test/kotlin/de/hoennig/werkator/metrics/CapacitySizingUnitTest.kt) `RAM is sized from the quarter-hour maxima where the tier has them, else from the averages (Scenario#123.01)`

#### Scenario#123.02: the RAM and disk verdicts stay as they were

So that splitting the two methods changes no advice a host has been given.

- **Given** the RAM and disk histories of the existing scenarios
- **When** the advice is derived
- **Then** each verdict, recommended size and reason is the same as before

##### Verified by

- [CapacitySizingUnitTest](../../src/test/kotlin/de/hoennig/werkator/metrics/CapacitySizingUnitTest.kt): the tests of Scenario#30, #42, #43, #58, #78 and #79, unchanged

#### Scenario#123.03: every verdict has its chip, threads whole and GiB with two decimals

So that the server-rendered chips read like the ones `werkator.js` renders after the first poll.

- **Given** an advice with the verdict `tight`, `downsize` or `upgrade`
- **When** its chip is worded
- **Then** it reads `watch, raise to …`, `downsize to …` or `upgrade to …`
  - **and** threads are whole, a fraction cut off, and GiB carry two decimals

##### Verified by

- [UiViewsUnitTest](../../src/test/kotlin/de/hoennig/werkator/server/UiViewsUnitTest.kt) `every verdict has its chip, threads whole and GiB with two decimals, as werkator.js words them (Scenario#123.03)`, and `advice chips read like werkator.js adviceLabel (Scenario#30.03)`

## The Solution

The pattern of PR#113 for the CPU, applied to RAM and disk.

`ram` checks that there is a history, builds a `RamUsage` (peak, usable and booked size, step, stalled share, fill line) and asks two steps:

- `ramStalled` raises when memory stalled in enough quarter hours, `null` otherwise;
- `ramUnstalled` decides between a downsize and fits.

`stalledShare` counts the stalled quarter hours, `null` without a pressure history.

`disk` builds a `DiskUsage` (level, peak, total, step, whether to ask, held line) and asks four steps in order, each `null` when it does not apply:

- `diskFull`: the level reaches the upgrade ratio;
- `diskGrowing`: the projected level reaches it within the horizon;
- `diskTight`: the level is in the watch band and needs more than the total;
- `diskSlack`: a downsize when the window maximum leaves room, else fits.

`column` reads `tier.values[key].orEmpty()` instead of an elvis to an empty list.

`adviceLabel` kept three survivors after the new tests: the `!!` on the recommended size compiles to a `checkNotNull` call, and removing it fails the same way one line later.
The size is now worded by `adviceSize`, which reads a missing size as `werkator.js` renders it, `0 threads` or `n/a GiB`.
No verdict but `fits` and `collecting` comes without a size, so this changes nothing a reader sees.

A local PIT run of `CapacitySizing` and `UiFormats` afterwards:

| Method                        | Mutation-CRAP | Complexity | Mutation score |
|-------------------------------|---------------|------------|----------------|
| `CapacitySizing.ram`          | 6.56          | 6          | 0.75           |
| `CapacitySizing.ramStalled`   | 5.40          | 4          | 0.56           |
| `CapacitySizing.ramUnstalled` | 7.33          | 6          | 0.67           |
| `CapacitySizing.stalledShare` | 3.33          | 3          | 0.67           |
| `CapacitySizing.column`       | 6             | 6          | 1.00           |
| `CapacitySizing.disk`         | 10.27         | 9          | 0.75           |
| `CapacitySizing.diskFull`     | 2.06          | 2          | 0.75           |
| `CapacitySizing.diskGrowing`  | 4.20          | 4          | 0.77           |
| `CapacitySizing.diskTight`    | 3.33          | 3          | 0.67           |
| `CapacitySizing.diskSlack`    | 5.20          | 5          | 0.80           |
| `UiFormats.adviceLabel`       | 5             | 5          | 1.00           |
| `UiFormats.adviceSize`        | 3             | 3          | 1.00           |

`UiFormats.repoStatusLine` (16.03) is the one method of these classes still above 15; it belongs to the build lists and follows in its own PR.
The project maximum stays `NginxProxyManager.resolveSettings` at 27.44, so the gate stays at 28.

## Additional Changes

- Version 1.37.4 with its entry in `releases.html`.

## Prerequisite PRs

- PR#113: the same split for the CPU advice.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: mhoennig <michael@hoennig.de>
Reviewed-on: #123
2026-10-05 18:58:30 +02:00

6.4 KiB

PR#123: RAM and Disk Sizing Advice Split into Their Steps

WARNING: This document describes only the change applied in this PR. It may already be outdated once the next PR is merged. Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.

The Problem

Four methods of the sizing advice are above the Kotlin target of 15, measured by the nightly mutation build of 2026-10-05:

Method Mutation-CRAP Complexity Mutation score
CapacitySizing.ram 26.87 15 0.63
CapacitySizing.column 26.83 6 0.17
CapacitySizing.disk 21.19 16 0.73
UiFormats.adviceLabel 15.55 5 0.25

ram and disk decide every verdict of their resource in one method each, as cpu did before PR#113. column and adviceLabel are short, but their tests never told the alternatives apart: every test tier carried the same values as averages and as maxima, and no test asked for a watch chip.

Non-Goals

  • No change in behaviour: every verdict, size and reason stays as it was.
  • The other groups above 15 follow in their own PRs.

The Scenarios

Feature: the sizing reads the right column and words every verdict

Scenario#123.01: RAM is sized from the quarter-hour maxima where the tier has them, else from the averages

So that a moment of overflow, which is what RAM is sized against, is never averaged away, and a tier without maxima still gets advice.

  • Given ten days of history whose RAM averages stay at 2.0 GiB
  • When the maxima hold one quarter hour at 4.4 GiB
  • Then the RAM peak is 4.4 GiB
  • When the tier has no maxima for RAM, or an empty column
  • Then the RAM peak is 2.0 GiB, from the averages
  • When the maxima hold only gaps
  • Then there is no peak at all, and a gap never counts as zero
Verified by
  • CapacitySizingUnitTest RAM is sized from the quarter-hour maxima where the tier has them, else from the averages (Scenario#123.01)

Scenario#123.02: the RAM and disk verdicts stay as they were

So that splitting the two methods changes no advice a host has been given.

  • Given the RAM and disk histories of the existing scenarios
  • When the advice is derived
  • Then each verdict, recommended size and reason is the same as before
Verified by

Scenario#123.03: every verdict has its chip, threads whole and GiB with two decimals

So that the server-rendered chips read like the ones werkator.js renders after the first poll.

  • Given an advice with the verdict tight, downsize or upgrade
  • When its chip is worded
  • Then it reads watch, raise to …, downsize to … or upgrade to …
    • and threads are whole, a fraction cut off, and GiB carry two decimals
Verified by
  • UiViewsUnitTest every verdict has its chip, threads whole and GiB with two decimals, as werkator.js words them (Scenario#123.03), and advice chips read like werkator.js adviceLabel (Scenario#30.03)

The Solution

The pattern of PR#113 for the CPU, applied to RAM and disk.

ram checks that there is a history, builds a RamUsage (peak, usable and booked size, step, stalled share, fill line) and asks two steps:

  • ramStalled raises when memory stalled in enough quarter hours, null otherwise;
  • ramUnstalled decides between a downsize and fits.

stalledShare counts the stalled quarter hours, null without a pressure history.

disk builds a DiskUsage (level, peak, total, step, whether to ask, held line) and asks four steps in order, each null when it does not apply:

  • diskFull: the level reaches the upgrade ratio;
  • diskGrowing: the projected level reaches it within the horizon;
  • diskTight: the level is in the watch band and needs more than the total;
  • diskSlack: a downsize when the window maximum leaves room, else fits.

column reads tier.values[key].orEmpty() instead of an elvis to an empty list.

adviceLabel kept three survivors after the new tests: the !! on the recommended size compiles to a checkNotNull call, and removing it fails the same way one line later. The size is now worded by adviceSize, which reads a missing size as werkator.js renders it, 0 threads or n/a GiB. No verdict but fits and collecting comes without a size, so this changes nothing a reader sees.

A local PIT run of CapacitySizing and UiFormats afterwards:

Method Mutation-CRAP Complexity Mutation score
CapacitySizing.ram 6.56 6 0.75
CapacitySizing.ramStalled 5.40 4 0.56
CapacitySizing.ramUnstalled 7.33 6 0.67
CapacitySizing.stalledShare 3.33 3 0.67
CapacitySizing.column 6 6 1.00
CapacitySizing.disk 10.27 9 0.75
CapacitySizing.diskFull 2.06 2 0.75
CapacitySizing.diskGrowing 4.20 4 0.77
CapacitySizing.diskTight 3.33 3 0.67
CapacitySizing.diskSlack 5.20 5 0.80
UiFormats.adviceLabel 5 5 1.00
UiFormats.adviceSize 3 3 1.00

UiFormats.repoStatusLine (16.03) is the one method of these classes still above 15; it belongs to the build lists and follows in its own PR. The project maximum stays NginxProxyManager.resolveSettings at 27.44, so the gate stays at 28.

Additional Changes

  • Version 1.37.4 with its entry in releases.html.

Prerequisite PRs

  • PR#113: the same split for the CPU advice.

🤖 Generated with Claude Code