Repository navigation
[rayapp] accept memory quantity strings in required_resources - #505
Conversation
There was a problem hiding this comment.
Code Review
This pull request adds support for parsing and normalizing Kubernetes-style memory quantity strings (such as '8Gi') into integer byte values within the required_resources of compute configurations, aligning the Go implementation with the Python SDK. Feedback focuses on improving robustness: first, by using exact float64 bounds when checking if a float64 fits within int64 limits to avoid precision issues; and second, by adding a safety check when looking up memory multipliers to prevent silent failures if the regular expression and multiplier map ever drift.
| if t > math.MaxInt64 || t < math.MinInt64 { | ||
| return 0, fmt.Errorf("value %v is out of range", t) | ||
| } |
There was a problem hiding this comment.
When checking if a float64 value fits within the range of int64, comparing against math.MaxInt64 directly can be imprecise. math.MaxInt64 (9223372036854775807) cannot be represented exactly as a float64 and rounds up to 9223372036854775808.0. This causes the check t > math.MaxInt64 to evaluate to false when t is exactly 9223372036854775808.0, leading to silent saturation to math.MaxInt64 during the int64(t) cast.
Using exact representable float64 bounds (9223372036854775808.0 and -9223372036854775808.0) avoids this rounding issue.
| if t > math.MaxInt64 || t < math.MinInt64 { | |
| return 0, fmt.Errorf("value %v is out of range", t) | |
| } | |
| if t >= 9223372036854775808.0 || t < -9223372036854775808.0 { | |
| return 0, fmt.Errorf("value %v is out of range", t) | |
| } |
| if !ok { | ||
| return 0, fmt.Errorf("invalid memory quantity %q", s) | ||
| } | ||
| mantissa.Mul(mantissa, new(big.Rat).SetInt64(memoryQuantityMultipliers[m[2]])) |
There was a problem hiding this comment.
Directly accessing memoryQuantityMultipliers[m[2]] without checking if the key exists is risky. If the regular expression memoryQuantityRe is updated in the future to match a new suffix but the memoryQuantityMultipliers map is not updated accordingly, this lookup will silently return 0 (the zero value of int64), resulting in a silent failure where the memory is parsed as 0 bytes.
Adding a check for key existence prevents this potential drift.
multiplier, ok := memoryQuantityMultipliers[m[2]]
if !ok {
return 0, fmt.Errorf("internal error: unhandled memory suffix %q", m[2])
}
mantissa.Mul(mantissa, new(big.Rat).SetInt64(multiplier))The API takes `required_resources.memory` as an integer number of bytes,
but every user-facing surface advertises Kubernetes quantity strings
("8Gi"). The SDK reconciles those in PhysicalResources.to_dict(for_api=
True) -> _parse_memory_string; the published bundle never touches the
SDK, since rayapp copies required_resources verbatim into ray-app.json
and the console clone path parses it straight into the backend's
PhysicalResources, whose `memory` is an Optional[int]. So "8Gi" passes
`anyscale compute-config create -f` and then 422s at launch:
body.config.head_node_type.required_resources.memory:
value is not a valid integer
Convert in the Go mirror too, so the two conversion paths agree and a
template can be authored the way the docs describe.
Parse with k8s.io/apimachinery's resource.ParseQuantity -- the same
parser Kubernetes uses, and the one the SDK reaches through
kubernetes.utils.quantity -- rather than hand-rolling the grammar, so
the accepted syntax cannot drift from what the docs describe. Pinned to
apimachinery v0.34.0, which needs only go 1.24; v0.37 would drag the go
directive to 1.26 and bump x/net.
Three rules on top of the parser, each because the value has to land in
an integer bytes field:
- Whole bytes only. Kubernetes tolerates a fraction ("100m" is 100
*milli*bytes) but its own docs say "this isn't useful to specify
since you must always assign whole numbers of bytes", and a fraction
could not survive PhysicalResources.memory anyway. This also removes
the one place the two parsers disagree: ParseQuantity().Value()
rounds up where the SDK's int(Decimal) truncates, and that only
differs for values we now reject.
- No negatives. Nothing downstream rejects them -- the backend only
range-checks int64 -- so a negative would reach launch as a nonsense
pod request. YAML parses `memory: -1` as an int, so the integer path
needs this too.
- Must contain a digit. ParseQuantity reads a bare suffix ("Gi", "k",
".") as zero, so a typo would silently become no memory at all. The
SDK rejects those.
TestParseMemoryBytesMatchesSDK runs every golden through the SDK
function this mirrors and compares, so the two cannot drift unnoticed;
it skips where python3 or the anyscale package is unavailable.
No effect on VM configs: this fires only on required_resources.memory,
which instance-type configs (every aws.yaml/gce.yaml in
anyscale/templates today) never set. Building anyscale/templates#940
with this binary gives byte-identical AWS and GCP configs and converts
only the K8S memory values.
Test: go test ./rayapp/ passes, coverage 87.5% (gate 80%); gofmt and go
vet clean. golangci-lint panics on this package on a clean tree too
(toolchain mismatch: built with go1.25, sources need go1.26).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Ray CI Test <rayci@ray.io>
bdb0884 to
da8634a
Compare
Bumps the pinned rayapp from v0.45.0 to v0.49.0. The only rayapp change in that range is ray-project/rayci#505: `required_resources` now accepts memory quantity strings (e.g. `16Gi`), as used in K8S compute configs. Checked that https://github.com/ray-project/rayci/releases/download/v0.49.0/rayapp-linux-amd64 returns 200 and the binary runs. `download_rayapp.sh` is used by the premerge `rayapp build all`, the template test pipeline (`rayapp test`), and product's `tmpl-publish/build.sh`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Problem
required_resources.memoryreaches the API as an integer number of bytes, but every user-facing surface advertises Kubernetes quantity strings — the declarative compute docs, and the SDK's own docstring ("Memory in bytes or Kubernetes format string (e.g. '4Gi')").The SDK reconciles the two on the way out, in
PhysicalResources.to_dict(for_api=True):The publish path never touches the SDK.
convertNodeCommonFieldscopiesrequired_resourcesthrough verbatim,rayapp buildbase64s it intoray-app.json, and the console clone path (workspaces_service._make_template_compute_config) parses that bundle straight into the backend'sPhysicalResources, whosememoryis anOptional[int]with no quantity parsing. So"8Gi"passesanyscale compute-config create -f— the path templates are tested on — and then 422s at launch:Confirmed against the live Azure staging API by replaying a built bundle through the legacy create path.
CPU/GPUsurvive the same trip only because they carryalias="cpu"/"gpu"plusallow_population_by_field_name.memoryhas no such escape hatch.Fix
Convert in the Go mirror too, so both conversion paths agree and a template can be authored the way the docs describe.
Parsing is done by
k8s.io/apimachinery'sresource.ParseQuantity— the same parser Kubernetes uses, and the one the SDK reaches throughkubernetes.utils.quantity— rather than a hand-rolled grammar, so the accepted syntax can't drift from what the docs describe. Pinned to v0.34.0, which needs only go 1.24; v0.37 would drag thegodirective to 1.26 and bumpx/net.Three rules on top of the parser, each because the value has to land in an integer bytes field:
100mis 100 millibytes — but its own docs say "this isn't useful to specify since you must always assign whole numbers of bytes", and a fraction can't survivePhysicalResources.memoryregardlessmemory: -1as an int, so the integer path needs this tooParseQuantityreads a bare suffix (Gi,k,.) as zero, so a typo would silently become no memory at all. The SDK rejects thoseThe whole-bytes rule also removes the one place the two parsers disagree:
ParseQuantity().Value()rounds up (3.7Mi→ 3879732) where the SDK'sint(Decimal)truncates (3879731). That difference only exists for values this now rejects, so agreement is structural rather than sampled.Testing
go test ./rayapp/passes; coverage 87.5% (gate 80%).gofmtandgo vetclean.TestParseMemoryBytesMatchesSDKruns every golden through the SDK's_parse_memory_stringand compares, so this cannot drift from the Python side. It skips wherepython3or theanyscalepackage is unavailable, following the existingexec.LookPath("bash")precedent in this package. It ran (not skipped) locally and passed.Golden table covers every accepted suffix so a wrong multiplier can't hide; the error table covers the rejected forms above plus
8gi,8GB,1K(uppercaseKisn't a k8s suffix),1 Gi, and a value that saturates int64.End to end against anyscale/templates: built every template on the #940 branch (56 at the time) with this binary and with the currently pinned release, and diffed every bundle. 115 compute configs compared; exactly 3 differ — the three K8S configs — and the only changed lines are the memory values:
All 112 AWS/GCP configs are byte-identical. This fires only on
required_resources.memory, which instance-type configs never set.The switch from the hand-rolled parser to
ParseQuantitywas checked for equivalence, not just for passing tests: the samepytorch-fsdpbundle built with each binary is byte-identical across all three of AWS, GCP and K8S.Notes
k8s.io/apimachinery+gopkg.in/inf.v0direct, andcbor/v2,gogo/protobuf,float16,sigs.k8s.io/jsonindirect. Thegodirective is unchanged at 1.25.2.golineshook rewrapped three pre-existing over-long error returns in this file; that churn is the hook's.PhysicalResources.memory— that's the one place all three paths converge, and it would let every client drop its copy. This unblocks the K8S pilot now.🤖 Generated with Claude Code