Skip to content

fix(scheduler): parse fractional and Pi/Ei resource quantities - #87

Merged
calfonso merged 1 commit into
calfonso:mainfrom
vyncint:fix/fractional-resource-quantities
Jul 29, 2026
Merged

calfonso merged 1 commit into
calfonso:mainfrom
vyncint:fix/fractional-resource-quantities

Conversation

@vyncint

@vyncint vyncint commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #86.

parse_resource_quantity parsed the digits of a suffixed quantity with str::parse::<i64>(), so every resource.Quantity carrying a decimal point silently fell back to unwrap_or(0). The upstream grammar in k8s.io/apimachinery/pkg/api/resource/quantity.go allows a decimal point with every suffix:

<number> ::= <digits> | <digits>.<digits> | <digits>. | .<digits>

so "0.5Gi" is as valid as "512Mi" and denotes the same 536870912 bytes.

Parsing it as 0 does not fail loudly — it reads to the scheduler as "this pod requests no memory", so the pod is admitted to any node regardless of remaining capacity. The same unwrap_or(0) applies to node allocatable, so a node advertising "1.5Ti" is accounted as having no memory at all and nothing schedules onto it.

Pi and Ei were missing from the binarySI set entirely and fell through to the same 0.

Verified against resource.ParseQuantity(s).Value() from k8s.io/[email protected]:

input before apimachinery
0.5Gi 0 536870912
1.5Gi 0 1610612736
2.5Mi 0 2621440
1.5G 0 1500000000
1Pi 0 1125899906842624
1Ei 0 1152921504606846976
1Gi 1073741824 1073741824
128M 128000000 128000000

Fix

Replace the hand-rolled strip_suffix chain with a QUANTITY_SUFFIXES table applied over an f64 parse:

  • Ordered longest-first, so "1Mi" cannot be read as "1M" followed by a stray i.
  • Bare numbers and the <decimalExponent> form are parsed directly first. Rust's float parser implements that production exactly and rejects "1E"/"1Gi", which then fall through to the table — so "129e6" stays 129000000 while "1E" still means exa.
  • Pi and Ei added to complete binarySI.
  • Non-finite input ("inf", "NaN", "1e400") is rejected instead of saturating to i64::MAX, which would otherwise advertise infinite node capacity.
  • CPU folds into the same path rather than needing a special case: m is the shared 10^-3 decimalSI suffix, so "100m" is 0.1 cores → 100 millicores. Result is rounded so "0.7" cores stays 700m.

scheduler.rs carried a byte-identical copy of the same function. It now imports the fixed one from advanced.rs instead of maintaining a second copy (net −93 lines there).

Behaviour for every input the previous implementation handled correctly is unchanged; the existing test_parse_cpu_quantity and test_parse_memory_quantity cases pass untouched.

Tests

Five new cases in crates/scheduler/src/advanced.rs:

  • test_parse_fractional_memory_quantity — 0.5Gi, 1.5Gi, 0.5Mi, 2.5Mi, 0.5Ki, 1.5G, 0.5M, 1.5k
  • test_parse_large_binary_suffixes — Ti, Pi, Ei
  • test_parse_decimal_si_and_exponent — 1T/1P/1E/1k, plus 129E6 and 1.5e3 as exponent forms
  • test_parse_cpu_fractional_and_milli — 100m, 1500m, 0.7, 2.5, 10.5m
  • test_parse_quantity_rejects_malformed_input — "", "abc", "Gi", "1Xi", "--5", "inf", "NaN", "1e400" all yield 0

Test plan

  • cargo test -p rusternetes-scheduler — 27/27 pass (was 22)
  • cargo test --workspace — 4209 passed, 0 failed (baseline on main: 4199 passed, 0 failed; +10 is the 5 new tests across the crate's lib and bin targets)
  • cargo fmt --all -- --check clean (checked over workspace members, as .github/workflows/fmt.yml does)
  • cargo clippy -p rusternetes-scheduler --all-targets --all-features — no new warnings; the only hits in the touched files are the pre-existing check_preemption / SYSTEM_CRITICAL_PRIORITY dead-code warnings

`parse_resource_quantity` parsed the digits of a suffixed quantity with
`str::parse::<i64>()`, so every `resource.Quantity` carrying a decimal
point silently fell back to `unwrap_or(0)`. The upstream grammar in
`k8s.io/apimachinery/pkg/api/resource/quantity.go` allows a decimal point
with every suffix:

    <number> ::= <digits> | <digits>.<digits> | <digits>. | .<digits>

so `"0.5Gi"` is as valid as `"512Mi"`. It parsed as 0, which the
scheduler reads as "this pod requests no memory" — the pod is then
admitted to any node regardless of remaining capacity. The same
`unwrap_or(0)` applied to node `allocatable`, so a node advertising
`"1.5Ti"` looked like it had no memory at all.

`Pi` and `Ei` were missing from the binarySI set entirely and fell
through to the same 0.

Replace the hand-rolled `strip_suffix` chain with a suffix table applied
over an `f64` parse, ordered longest-first so `"1Mi"` cannot be read as
`"1M"`. Bare numbers and the `<decimalExponent>` form are parsed
directly, which keeps `"129e6"` working and lets `"1E"` fall through to
the exa suffix. Non-finite input (`"inf"`, `"1e400"`) is now rejected
rather than saturating to `i64::MAX`, which would have advertised
infinite node capacity.

CPU folds into the same path: `m` is the shared 10^-3 decimalSI suffix,
so `"100m"` is 0.1 cores and converts to 100 millicores without a
special case.

`scheduler.rs` carried a byte-identical copy of the same function; it now
shares the fixed one instead.

Signed-off-by: Vyncint Ng <[email protected]>
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Signed-off-by: Vyncint Ng <[email protected]>
Copilot AI review requested due to automatic review settings July 27, 2026 23:31

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@calfonso
calfonso merged commit 9a30ea5 into calfonso:main Jul 29, 2026
2 checks passed
indyjonesnl added a commit to indyjonesnl/k3rs that referenced this pull request Jul 29, 2026
`parse_resource_quantity` parsed the digits of a suffixed quantity with
`str::parse::<i64>()`, so every `resource.Quantity` carrying a decimal
point fell back to `unwrap_or(0)`. The upstream grammar in
`k8s.io/apimachinery/pkg/api/resource/quantity.go` permits a decimal
point with every suffix:

    <number> ::= <digits> | <digits>.<digits> | <digits>. | .<digits>

so `"0.5Gi"` is as valid as `"512Mi"` and denotes the same 536870912
bytes. Parsing it as 0 does not fail loudly — it reads as "this pod
requests no memory", so the pod is admitted to any node regardless of
remaining capacity. The same fallback applies to node `allocatable`, so
a node advertising `"1.5Ti"` is accounted as having no memory and
nothing schedules onto it. `Pi`/`Ei` were missing from the binarySI set
and hit the same path. `"inf"` went the other way: it parses as an f64
and `as i64` saturated it to `i64::MAX`, advertising infinite capacity.

Rather than hand-roll a third suffix table, route both scheduler copies
through `rusternetes_common::quantity::Quantity` — the in-tree port of
apimachinery's parser — and account CPU in millicores via
`milli_value()` and everything else via `value()`, mirroring upstream
`Resource.Add` (`pkg/scheduler/framework/types.go:917-918`). That gets
exact i128 arithmetic and upstream's ceiling rounding instead of f64
approximation. `scheduler.rs` carried a byte-identical copy of the old
function; it now imports the shared one.

`Quantity::scaled_value` computed `10^delta` with an unchecked `pow`,
which panicked with "attempt to multiply with overflow" on `"1e400"`
(debug) and wrapped to a nonsense value in release. Both directions now
saturate the way upstream `ScaledValue` does, and a non-zero quantity
below the target scale rounds up away from zero rather than to 0,
matching `ParseQuantity`'s `inf.RoundUp` rationale.

Tests ported from calfonso/rusternetes#87 (thanks @vyncint) — the
fractional, Pi/Ei, decimal-exponent and malformed-input cases, with
expected values checked against `resource.ParseQuantity(s).Value()`.
The `"1e400"` case asserts upstream's saturation instead of 0:
`ParseQuantity` only caps BinarySI (`maxAllowed`), so a DecimalExponent
quantity is valid and saturates in `ScaledValue`.

Consolidating the ~8 remaining bespoke quantity parsers (kubelet
eviction/runtime/translate, vpa, hpa, admission) onto `Quantity` is
tracked separately.

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Scheduler parses fractional resource quantities (0.5Gi, 1.5Ti) as 0

3 participants