From ab16391a77ea75a3d12252f5fccf6e5e347603c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1chym=20Metli=C4=8Dka?= Date: Tue, 28 Apr 2026 21:20:48 +0200 Subject: [PATCH 1/2] feat: add configurable worker isolation and max heap size for code generation --- .../README.adoc | 21 +++++++ .../gradle/plugin/OpenApiGeneratorPlugin.kt | 2 + .../OpenApiGeneratorGenerateExtension.kt | 22 +++++++ .../gradle/plugin/tasks/GenerateTask.kt | 58 ++++++++++++++++++- 4 files changed, 101 insertions(+), 2 deletions(-) diff --git a/modules/openapi-generator-gradle-plugin/README.adoc b/modules/openapi-generator-gradle-plugin/README.adoc index 847428966cd8..6efd4aab43bc 100644 --- a/modules/openapi-generator-gradle-plugin/README.adoc +++ b/modules/openapi-generator-gradle-plugin/README.adoc @@ -440,6 +440,27 @@ apply plugin: 'org.openapi.generator' |false |Defines whether the generator should run in dry-run mode. In dry-run mode no files are written and a summary about file states is output. + +|workerIsolation +|String / Provider +|`process` +a|Controls how the code-generation work action is isolated from the Gradle daemon. + +`process` (default):: Runs generation in a separate forked JVM. Generator classes are fully unloaded when the worker +exits, preventing Metaspace accumulation in the Gradle daemon. A small per-task JVM startup cost is incurred (~1–2 s +amortized across parallel builds). Recommended for CI/CD and multi-project builds to avoid the +https://docs.gradle.org/current/userguide/build_environment.html#sec:configuring_jvm_memory[metaspace exhaustion +warning]. + +`classloader`:: Runs generation inside the Gradle daemon JVM using a separate `ClassLoader`. Avoids the per-task JVM +startup overhead, but generator classes accumulate in the daemon's Metaspace across tasks and builds. Suitable for +single-module projects or local developer loops where Metaspace pressure is not a concern. + +|maxWorkerHeapSize +|String / Provider +|Gradle default (~512 MiB) +|Maximum heap size for the forked worker JVM when `workerIsolation` is `process` (e.g. `"512m"`, `"1g"`). +Has no effect when `workerIsolation` is `classloader`. |=== [NOTE] diff --git a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/OpenApiGeneratorPlugin.kt b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/OpenApiGeneratorPlugin.kt index 6c2430a2c93b..901383ac6378 100644 --- a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/OpenApiGeneratorPlugin.kt +++ b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/OpenApiGeneratorPlugin.kt @@ -157,6 +157,8 @@ class OpenApiGeneratorPlugin : Plugin { engine.set(generate.engine) cleanupOutput.set(generate.cleanupOutput) dryRun.set(generate.dryRun) + workerIsolation.set(generate.workerIsolation) + maxWorkerHeapSize.set(generate.maxWorkerHeapSize) } } } diff --git a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt index ec87d07f56e5..13e989a31a44 100644 --- a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt +++ b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt @@ -409,6 +409,28 @@ open class OpenApiGeneratorGenerateExtension(private val project: Project) { */ val dryRun = project.objects.property() + /** + * Controls how the code generation worker is isolated from the Gradle daemon. + * + * - "process" (default): runs in a separate JVM. Metaspace is isolated from the daemon and freed + * when the worker exits. Gradle reuses the worker process across tasks that share the same + * classpath, so the JVM startup cost is typically paid only once per parallel slot. + * Best for projects with many generation tasks. + * + * - "classloader": runs inside the Gradle daemon JVM with a separate ClassLoader. No process + * startup overhead, but generator classes accumulate in daemon Metaspace. Suitable for projects + * with very few generation tasks. + */ + val workerIsolation = project.objects.property() + + /** + * Maximum heap size for the worker process when [workerIsolation] is "process" (e.g. "512m", "1g"). + * Has no effect when [workerIsolation] is "classloader". + * When not set, the JVM uses ergonomic defaults (typically based on available system memory). + * Only set this if you hit OutOfMemoryError during generation of unusually large specs. + */ + val maxWorkerHeapSize = project.objects.property() + init { applyDefaults() } diff --git a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt index 2b9fd47e151d..64baeed303b1 100644 --- a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt +++ b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt @@ -282,6 +282,33 @@ abstract class GenerateTask : DefaultTask() { @get:Inject abstract val layout: ProjectLayout + /** + * Controls how the code generation worker is isolated from the Gradle daemon. + * + * - "process" (default): runs in a separate JVM process. Metaspace is fully isolated from the + * daemon and freed after the process exits. Gradle reuses the worker process across tasks that + * share the same classpath, so the JVM startup cost is paid at most once per parallel slot — + * not once per task. Best for projects with many generation tasks. + * + * - "classloader": runs inside the Gradle daemon JVM using a separate ClassLoader. No process + * startup overhead, but each task loads generator classes into the daemon's Metaspace. With + * many tasks this can exhaust Metaspace. Suitable for projects with very few tasks where the + * daemon memory budget is not a concern. + */ + @get:Optional + @get:Input + abstract val workerIsolation: Property + + /** + * Maximum heap size for the worker process when [workerIsolation] is "process" (e.g. "512m", "1g"). + * Has no effect when [workerIsolation] is "classloader". + * When not set, the JVM uses ergonomic defaults (typically based on available system memory). + * Only set this if you hit OutOfMemoryError during generation of unusually large specs. + */ + @get:Optional + @get:Input + abstract val maxWorkerHeapSize: Property + /** * The verbosity of generation */ @@ -863,8 +890,35 @@ abstract class GenerateTask : DefaultTask() { } } -// Submit generation logic to the isolated Worker API Queue - val workQueue = workerExecutor.classLoaderIsolation() +// Submit generation work using the configured isolation mode. +// "process" (default): worker runs in a separate JVM; Metaspace is freed after each worker daemon +// exits, and Gradle reuses the same worker daemon across tasks that share the same classpath, +// so startup cost is amortized — typically paid only once per parallel slot. +// "classloader": worker runs inside the Gradle daemon JVM with a separate ClassLoader; no startup +// overhead but generator classes accumulate in daemon Metaspace across all tasks. + val isolation = workerIsolation.getOrElse("process").lowercase() + val workQueue = when (isolation) { + "classloader" -> { + logger.lifecycle( + "[openApiGenerate] Worker isolation: classloader " + + "(fast startup, but generator classes accumulate in Gradle daemon Metaspace - " + + "consider workerIsolation = \"process\" if you hit metaspace pressure)" + ) + workerExecutor.classLoaderIsolation() + } + + else -> { + val heapMsg = maxWorkerHeapSize.orNull?.let { " (maxHeapSize=$it)" } ?: "" + logger.lifecycle( + "[openApiGenerate] Worker isolation: process$heapMsg " + + "(isolated JVM per task, no Metaspace leak - " + + "use workerIsolation = \"classloader\" to skip per-task JVM startup cost at the cost of increased Metaspace usage)" + ) + workerExecutor.processIsolation { + maxWorkerHeapSize.orNull?.let { forkOptions.maxHeapSize = it } + } + } + } workQueue.submit(OpenApiWorkAction::class.java, object : Action { override fun execute(parameters: OpenApiWorkParameters) { From e21f51fcdd56d3f9a8dd51698778fbce44b985ef Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 29 Apr 2026 00:49:22 +0200 Subject: [PATCH 2/2] implement feedback from CR --- .../README.adoc | 10 +++---- .../OpenApiGeneratorGenerateExtension.kt | 10 +++---- .../gradle/plugin/tasks/GenerateTask.kt | 30 ++++++++++--------- 3 files changed, 26 insertions(+), 24 deletions(-) diff --git a/modules/openapi-generator-gradle-plugin/README.adoc b/modules/openapi-generator-gradle-plugin/README.adoc index 6efd4aab43bc..73150eee039c 100644 --- a/modules/openapi-generator-gradle-plugin/README.adoc +++ b/modules/openapi-generator-gradle-plugin/README.adoc @@ -446,16 +446,16 @@ file states is output. |`process` a|Controls how the code-generation work action is isolated from the Gradle daemon. -`process` (default):: Runs generation in a separate forked JVM. Generator classes are fully unloaded when the worker +`classloader` (default):: Runs generation inside the Gradle daemon JVM using a separate `ClassLoader`. Avoids the per-task JVM +startup overhead, but generator classes accumulate in the daemon's Metaspace across tasks and builds. Suitable for +single-module projects or local developer loops where Metaspace pressure is not a concern. + +`process`:: Runs generation in a separate forked JVM. Generator classes are fully unloaded when the worker exits, preventing Metaspace accumulation in the Gradle daemon. A small per-task JVM startup cost is incurred (~1–2 s amortized across parallel builds). Recommended for CI/CD and multi-project builds to avoid the https://docs.gradle.org/current/userguide/build_environment.html#sec:configuring_jvm_memory[metaspace exhaustion warning]. -`classloader`:: Runs generation inside the Gradle daemon JVM using a separate `ClassLoader`. Avoids the per-task JVM -startup overhead, but generator classes accumulate in the daemon's Metaspace across tasks and builds. Suitable for -single-module projects or local developer loops where Metaspace pressure is not a concern. - |maxWorkerHeapSize |String / Provider |Gradle default (~512 MiB) diff --git a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt index 13e989a31a44..556d6acb72a6 100644 --- a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt +++ b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt @@ -412,14 +412,14 @@ open class OpenApiGeneratorGenerateExtension(private val project: Project) { /** * Controls how the code generation worker is isolated from the Gradle daemon. * - * - "process" (default): runs in a separate JVM. Metaspace is isolated from the daemon and freed + * - "classloader" (default): runs inside the Gradle daemon JVM with a separate ClassLoader. No process + * startup overhead, but generator classes accumulate in daemon Metaspace. Suitable for projects + * with very few generation tasks. + * + * - "process": runs in a separate JVM. Metaspace is isolated from the daemon and freed * when the worker exits. Gradle reuses the worker process across tasks that share the same * classpath, so the JVM startup cost is typically paid only once per parallel slot. * Best for projects with many generation tasks. - * - * - "classloader": runs inside the Gradle daemon JVM with a separate ClassLoader. No process - * startup overhead, but generator classes accumulate in daemon Metaspace. Suitable for projects - * with very few generation tasks. */ val workerIsolation = project.objects.property() diff --git a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt index 64baeed303b1..fa8be13d5cdd 100644 --- a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt +++ b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt @@ -891,23 +891,14 @@ abstract class GenerateTask : DefaultTask() { } // Submit generation work using the configured isolation mode. -// "process" (default): worker runs in a separate JVM; Metaspace is freed after each worker daemon +// "classloader" (default): worker runs inside the Gradle daemon JVM with a separate ClassLoader; no startup +// overhead but generator classes accumulate in daemon Metaspace across all tasks. +// "process": worker runs in a separate JVM; Metaspace is freed after each worker daemon // exits, and Gradle reuses the same worker daemon across tasks that share the same classpath, // so startup cost is amortized — typically paid only once per parallel slot. -// "classloader": worker runs inside the Gradle daemon JVM with a separate ClassLoader; no startup -// overhead but generator classes accumulate in daemon Metaspace across all tasks. - val isolation = workerIsolation.getOrElse("process").lowercase() + val isolation = workerIsolation.getOrElse("classloader").lowercase() val workQueue = when (isolation) { - "classloader" -> { - logger.lifecycle( - "[openApiGenerate] Worker isolation: classloader " + - "(fast startup, but generator classes accumulate in Gradle daemon Metaspace - " + - "consider workerIsolation = \"process\" if you hit metaspace pressure)" - ) - workerExecutor.classLoaderIsolation() - } - - else -> { + "process" -> { val heapMsg = maxWorkerHeapSize.orNull?.let { " (maxHeapSize=$it)" } ?: "" logger.lifecycle( "[openApiGenerate] Worker isolation: process$heapMsg " + @@ -918,6 +909,17 @@ abstract class GenerateTask : DefaultTask() { maxWorkerHeapSize.orNull?.let { forkOptions.maxHeapSize = it } } } + + "classloader" -> { + logger.lifecycle( + "[openApiGenerate] Worker isolation: classloader " + + "(fast startup, but generator classes accumulate in Gradle daemon Metaspace - " + + "consider workerIsolation = \"process\" if you hit metaspace pressure)" + ) + workerExecutor.classLoaderIsolation() + } + + else -> throw GradleException("Invalid workerIsolation mode: $isolation. Supported values are 'process' and 'classloader'.") } workQueue.submit(OpenApiWorkAction::class.java, object : Action {