Skip to content
30 changes: 29 additions & 1 deletion RUBY_VS_GO_BUILDPACK_COMPARISON.md
Original file line number Diff line number Diff line change
Expand Up @@ -2191,12 +2191,40 @@ dependencies:
| **APM Agents** | ✅ 15 agents | ✅ 14 agents | Missing: Google Stackdriver Debugger (deprecated) |
| **Security Providers** | ✅ 6 | ✅ 6 | Identical |
| **Database JDBC Injection** | ✅ | ✅ | Identical |
| **Memory Calculator** | ✅ | ✅ | Identical |
| **Memory Calculator** | ✅ v3.13.0 | ✅ v4.2.0 | **Behaviour change** — see below |
| **JVMKill Agent** | ✅ | ✅ | Identical |
| **Custom JRE Repositories** | ✅ Runtime config | ❌ Requires fork | Breaking change |
| **Multi-buildpack** | ⚠️ Via framework | ✅ Native V3 | Go improvement |
| **Configuration Overrides** | ✅ | ✅ | Identical (JBP_CONFIG_*) |

#### Memory Calculator Behaviour Change (v3 → v4)

The memory calculator was upgraded from **v3.13.0 to v4.2.0**. The difference only affects apps with an **explicit `-Xmx`** in `JAVA_OPTS` (setting `-Xmx` explicitly in containerised environments is generally considered bad practice — the calculator sizes heap better automatically). How a pinned `-Xmx` is handled:

| | v3.13.0 (Ruby) | v4.2.0 (Go) |
|--|----------------|-------------|
| Memory check | `non-heap > total` | `non-heap + heap > total` |
| Non-heap when `-Xmx` set | Squeezed to `total − Xmx` | Calculated independently |
| `-Xmx512M`, container=750M | ✅ passes | ❌ fails |

When `-Xmx` is not set, both v3 and v4 size heap and non-heap to fit within the container — no difference. When `-Xmx` is pinned, v4 requires the container to fit both heap and non-heap (thread stacks + metaspace + code cache). v3 squeezed non-heap into whatever remained after `-Xmx`, claiming less total memory — at the cost of potentially undersized thread stacks and metaspace at runtime. Apps that fit in smaller containers with v3 may fail at startup with v4:

```
required memory 1269289K is greater than 750M available for allocation
```

**Migration options**:

1. **Lower `stack_threads`** *(only if your app uses fewer than 250 threads)*: 250 threads × ~1M = ~250M native memory. Reducing this alone is often enough to fit within the container:
```yaml
env:
JBP_CONFIG_OPEN_JDK_JRE: '{ memory_calculator: { stack_threads: 50 } }'
```

2. **Remove `-Xmx` from `JAVA_OPTS`** — let the calculator size heap automatically. Note: removing `-Xmx` avoids the fixed-heap check but does not reduce total memory need. You will likely still need to increase `memory:` in `manifest.yml` so the calculator has enough room to allocate adequate heap.

3. **Increase manifest memory** — raise `memory:` to fit `Xmx + non-heap`. Based on the error above (`1269289K ≈ 1240M`), set at least **1300M** for a `-Xmx512M` app with default settings.

### 10.3 Adoption Recommendations

**✅ RECOMMENDED for**:
Expand Down
4 changes: 2 additions & 2 deletions src/java/jres/graalvm.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ func (g *GraalVMJRE) Supply() error {
}

// Install Memory Calculator
g.memoryCalc = NewMemoryCalculator(g.ctx, g.jreDir, g.version, javaMajorVersion)
g.memoryCalc = NewMemoryCalculator(g.ctx, g.jreDir, g.version, javaMajorVersion, "graalvm")
if err := g.memoryCalc.Supply(); err != nil {
g.ctx.Log.Warning("Failed to install Memory Calculator: %s (continuing)", err.Error())
// Non-fatal - continue without memory calculator
Expand Down Expand Up @@ -143,7 +143,7 @@ func (g *GraalVMJRE) Finalize() error {

// Reconstruct Memory Calculator component if not already set
if g.memoryCalc == nil {
g.memoryCalc = NewMemoryCalculator(g.ctx, g.jreDir, g.version, javaMajorVersion)
g.memoryCalc = NewMemoryCalculator(g.ctx, g.jreDir, g.version, javaMajorVersion, "graalvm")
}

// Finalize Memory Calculator
Expand Down
4 changes: 2 additions & 2 deletions src/java/jres/ibm.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ func (i *IBMJRE) Supply() error {
}

// Install Memory Calculator
i.memoryCalc = NewMemoryCalculator(i.ctx, i.jreDir, i.version, javaMajorVersion)
i.memoryCalc = NewMemoryCalculator(i.ctx, i.jreDir, i.version, javaMajorVersion, "ibm")
if err := i.memoryCalc.Supply(); err != nil {
i.ctx.Log.Warning("Failed to install Memory Calculator: %s (continuing)", err.Error())
// Non-fatal - continue without memory calculator
Expand Down Expand Up @@ -146,7 +146,7 @@ func (i *IBMJRE) Finalize() error {

// Reconstruct Memory Calculator component if not already set
if i.memoryCalc == nil {
i.memoryCalc = NewMemoryCalculator(i.ctx, i.jreDir, i.version, javaMajorVersion)
i.memoryCalc = NewMemoryCalculator(i.ctx, i.jreDir, i.version, javaMajorVersion, "ibm")
}

// Finalize Memory Calculator
Expand Down
122 changes: 106 additions & 16 deletions src/java/jres/memory_calculator.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,26 @@ type MemoryCalculator struct {
jreDir string
jreVersion string
javaMajorVersion int
jreName string
calculatorPath string
version string
classCount int
stackThreads int
headroom int
classCount int
stackThreads int
headroom int
configLoaded bool
classCountUserSet bool
stackThreadsUserSet bool
headroomUserSet bool
}

// NewMemoryCalculator creates a new memory calculator
func NewMemoryCalculator(ctx *common.Context, jreDir, jreVersion string, javaMajorVersion int) *MemoryCalculator {
func NewMemoryCalculator(ctx *common.Context, jreDir, jreVersion string, javaMajorVersion int, jreName string) *MemoryCalculator {
return &MemoryCalculator{
ctx: ctx,
jreDir: jreDir,
jreVersion: jreVersion,
javaMajorVersion: javaMajorVersion,
jreName: jreName,
stackThreads: DefaultStackThreads,
headroom: DefaultHeadroom,
}
Expand All @@ -54,6 +60,8 @@ func (m *MemoryCalculator) Supply() error {
m.version = dep.Version
m.ctx.Log.Info("Installing Memory Calculator (%s)", m.version)

m.LoadConfig()

// Create bin directory
binDir := filepath.Join(m.jreDir, "bin")
if err := os.MkdirAll(binDir, 0755); err != nil {
Expand Down Expand Up @@ -107,14 +115,15 @@ func (m *MemoryCalculator) Supply() error {

m.calculatorPath = finalPath

// Count classes in the application
if err := m.countClasses(); err != nil {
m.ctx.Log.Warning("Failed to count classes: %s (using default)", err.Error())
m.classCount = 0 // Will be calculated as 35% of actual later
// Count classes in the application, unless overridden by config
if m.classCount == 0 {
if err := m.countClasses(); err != nil {
m.ctx.Log.Warning("Failed to count classes: %s (using default)", err.Error())
}
}

m.ctx.Log.Info("Memory Calculator installed: Loaded Classes: %d, Threads: %d",
m.classCount, m.stackThreads)
m.ctx.Log.Info("Memory Calculator installed: Loaded Classes: %s, Threads: %s, Headroom: %s",
m.classCountDisplay(), m.stackThreadsDisplay(), m.headroomDisplay())

// Clean up temp directory
os.RemoveAll(tempDir)
Expand Down Expand Up @@ -152,6 +161,8 @@ func (m *MemoryCalculator) detectInstalledCalculator() {

// Finalize configures the memory calculator in the startup command
func (m *MemoryCalculator) Finalize() error {
m.LoadConfig()

// If calculatorPath not set, try to detect it from previous installation
if m.calculatorPath == "" {
m.detectInstalledCalculator()
Expand Down Expand Up @@ -364,24 +375,82 @@ func (m *MemoryCalculator) convertToRuntimePath(stagingPath string) string {
return fmt.Sprintf("/home/vcap/deps/%s/jre/bin/%s", depsIdx, filename)
}

// LoadConfig loads memory calculator configuration from environment/config
// openJDKJREConfig mirrors the memory_calculator section of JBP_CONFIG_OPEN_JDK_JRE.
type openJDKJREConfig struct {
MemoryCalculator memoryCalculatorConfig `yaml:"memory_calculator"`
}

type memoryCalculatorConfig struct {
StackThreads int `yaml:"stack_threads"`
ClassCount int `yaml:"class_count"`
Headroom int `yaml:"headroom"`
}

// LoadConfig loads memory calculator configuration from the JRE-specific env var
// (e.g. JBP_CONFIG_OPEN_JDK_JRE, JBP_CONFIG_SAP_MACHINE_JRE) and falls back
// to MEMORY_CALCULATOR_* env vars.
// Must be called at the start of Supply(), before countClasses().
func (m *MemoryCalculator) LoadConfig() {
// Check for environment overrides
// JBP_CONFIG_OPEN_JDK_JRE='{memory_calculator: {stack_threads: 300}}'
if m.configLoaded {
return
}
m.configLoaded = true

// Resolve the env var name for this JRE (e.g. "sapmachine" → "JBP_CONFIG_SAP_MACHINE_JRE")
envVarName := jreNameToDocumentedEnvVar[m.jreName]
if envVarName == "" {
// fallback: auto-generate from jreName
envVarName = fmt.Sprintf("JBP_CONFIG_%s", strings.ToUpper(strings.ReplaceAll(m.jreName, "-", "_")))
}

if config := os.Getenv(envVarName); config != "" {
yamlHandler := common.YamlHandler{}

// Extract raw memory_calculator sub-section to validate its fields separately,
// so unknown top-level keys (e.g. jre:) are silently ignored while typos
// inside memory_calculator: are warned about.
rawCfg := struct {
MC interface{} `yaml:"memory_calculator"`
}{}
if err := yamlHandler.Unmarshal([]byte(config), &rawCfg); err == nil && rawCfg.MC != nil {
if mcBytes, err := yamlHandler.Marshal(rawCfg.MC); err == nil {
if err := yamlHandler.ValidateFields(mcBytes, &memoryCalculatorConfig{}); err != nil {
m.ctx.Log.Warning("Unknown fields in %s memory_calculator: %s", envVarName, err.Error())
}
}
}

// For now, using defaults
// In production, we'd parse JSON from environment variables
cfg := openJDKJREConfig{}
if err := yamlHandler.Unmarshal([]byte(config), &cfg); err != nil {
m.ctx.Log.Warning("Failed to parse %s: %s", envVarName, err.Error())
} else {
mc := cfg.MemoryCalculator
if mc.StackThreads > 0 {
m.stackThreads = mc.StackThreads
m.stackThreadsUserSet = true
}
if mc.ClassCount > 0 {
m.classCount = mc.ClassCount
m.classCountUserSet = true
}
if mc.Headroom > 0 {
m.headroom = mc.Headroom
m.headroomUserSet = true
}
}
}

// Check specific environment variables
if val := os.Getenv("MEMORY_CALCULATOR_STACK_THREADS"); val != "" {
if threads, err := strconv.Atoi(val); err == nil {
m.stackThreads = threads
m.stackThreadsUserSet = true
}
}

if val := os.Getenv("MEMORY_CALCULATOR_HEADROOM"); val != "" {
if headroom, err := strconv.Atoi(val); err == nil {
m.headroom = headroom
m.headroomUserSet = true
}
}
}
Expand All @@ -395,6 +464,27 @@ func copyFile(src, dst string) error {
return os.WriteFile(dst, data, 0755)
}

func (m *MemoryCalculator) classCountDisplay() string {
if m.classCountUserSet {
return fmt.Sprintf("%d", m.classCount)
}
return fmt.Sprintf("%d (auto-detected)", m.classCount)
}

func (m *MemoryCalculator) stackThreadsDisplay() string {
if m.stackThreadsUserSet {
return fmt.Sprintf("%d", m.stackThreads)
}
return fmt.Sprintf("%d (default)", m.stackThreads)
}

func (m *MemoryCalculator) headroomDisplay() string {
if m.headroomUserSet {
return fmt.Sprintf("%d%%", m.headroom)
}
return fmt.Sprintf("%d%% (default)", m.headroom)
}

// RunMemoryCalculator runs the memory calculator and returns the calculated JAVA_OPTS
// This is primarily for testing
func (m *MemoryCalculator) RunMemoryCalculator(memoryLimit string) (string, error) {
Expand Down
Loading