diff --git a/buildSrc/src/main/kotlin/GenerateVersionProviderTask.kt b/buildSrc/src/main/kotlin/GenerateVersionProviderTask.kt new file mode 100644 index 000000000..19b718034 --- /dev/null +++ b/buildSrc/src/main/kotlin/GenerateVersionProviderTask.kt @@ -0,0 +1,110 @@ +import org.gradle.api.DefaultTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction +import java.io.File + +/** + * Gradle task that generates a SmithyVersionProvider SPI implementation for a module. + * + * When [generateInterface] is true, also generates the interface and record classes + * for modules that don't have them on their compile classpath (i.e., modules that + * don't depend on core). + */ +abstract class GenerateVersionProviderTask : DefaultTask() { + + @get:Input + var moduleName: String = "" + + @get:Input + var moduleVersion: String = "" + + @get:Input + var generateInterface: Boolean = false + + @get:OutputDirectory + var outputDir: File = project.layout.buildDirectory.dir("generated/version-provider").get().asFile + + companion object { + private const val PACKAGE = "software.amazon.smithy.java.versionspi" + private const val INTERFACE_NAME = "SmithyVersionProvider" + private const val RECORD_NAME = "ModuleVersion" + private const val IMPL_NAME = "GeneratedVersionProvider" + } + + @TaskAction + fun generate() { + val parts = moduleVersion.split(".") + val major = parts.getOrElse(0) { "0" } + val minor = parts.getOrElse(1) { "0" } + val patch = parts.getOrElse(2) { "0" }.replace(Regex("[^0-9].*"), "") + + val packageDir = File(outputDir, "java/${PACKAGE.replace('.', '/')}") + packageDir.mkdirs() + + if (generateInterface) { + File(packageDir, "$INTERFACE_NAME.java").writeText( + """ + |package $PACKAGE; + | + |public interface $INTERFACE_NAME { + | $RECORD_NAME getModuleVersion(); + |} + """.trimMargin() + ) + + File(packageDir, "$RECORD_NAME.java").writeText( + """ + |package $PACKAGE; + | + |public record $RECORD_NAME(String moduleName, int major, int minor, int patch) implements Comparable<$RECORD_NAME> { + | @Override + | public int compareTo($RECORD_NAME other) { + | int c = Integer.compare(major, other.major); + | if (c != 0) { + | return c; + | } + | c = Integer.compare(minor, other.minor); + | if (c != 0) { + | return c; + | } + | return Integer.compare(patch, other.patch); + | } + | + | public String versionString() { + | return major + "." + minor + "." + patch; + | } + | + | @Override + | public String toString() { + | return moduleName + "=" + versionString(); + | } + |} + """.trimMargin() + ) + } + + // Always generate the implementation + File(packageDir, "$IMPL_NAME.java").writeText( + """ + |package $PACKAGE; + | + |public final class $IMPL_NAME implements $INTERFACE_NAME { + | private static final $RECORD_NAME VERSION = new $RECORD_NAME("$moduleName", $major, $minor, $patch); + | + | @Override + | public $RECORD_NAME getModuleVersion() { + | return VERSION; + | } + |} + """.trimMargin() + ) + + // Always generate META-INF/services file + val servicesDir = File(outputDir, "resources/META-INF/services") + servicesDir.mkdirs() + File(servicesDir, "$PACKAGE.$INTERFACE_NAME").writeText( + "$PACKAGE.$IMPL_NAME\n" + ) + } +} diff --git a/buildSrc/src/main/kotlin/smithy-java.module-conventions.gradle.kts b/buildSrc/src/main/kotlin/smithy-java.module-conventions.gradle.kts index 3a53f7f10..22384cb38 100644 --- a/buildSrc/src/main/kotlin/smithy-java.module-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/smithy-java.module-conventions.gradle.kts @@ -50,6 +50,23 @@ afterEvaluate { attributes(mapOf("Automatic-Module-Name" to moduleName)) } } + + // Generate a SmithyVersionProvider SPI implementation for this module. + if (!project.plugins.hasPlugin("software.amazon.smithy.gradle.smithy-jar")) { + val dependsOnCore = project.path == ":core" || project.configurations.getByName("compileClasspath") + .resolvedConfiguration.resolvedArtifacts.any { + it.moduleVersion.id.group == "software.amazon.smithy.java" && it.moduleVersion.id.name == "core" + } + val generateVersionProvider = tasks.register("generateVersionProvider") { + this.moduleName = moduleName + this.moduleVersion = smithyJavaVersion + this.generateInterface = !dependsOnCore + } + sourceSets["main"].java.srcDir(generateVersionProvider.map { it.outputDir.resolve("java") }) + sourceSets["main"].resources.srcDir(generateVersionProvider.map { it.outputDir.resolve("resources") }) + tasks.named("compileJava") { dependsOn(generateVersionProvider) } + tasks.named("processResources") { dependsOn(generateVersionProvider) } + } } // Always run javadoc after build. diff --git a/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/client/generators/ClientImplementationGenerator.java b/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/client/generators/ClientImplementationGenerator.java index 15f8940d0..bb06cc00d 100644 --- a/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/client/generators/ClientImplementationGenerator.java +++ b/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/client/generators/ClientImplementationGenerator.java @@ -27,7 +27,10 @@ import software.amazon.smithy.java.codegen.sections.ApplyDocumentation; import software.amazon.smithy.java.codegen.sections.ClassSection; import software.amazon.smithy.java.codegen.writer.JavaWriter; +import software.amazon.smithy.java.core.Version; +import software.amazon.smithy.java.core.VersionCheck; import software.amazon.smithy.java.core.serde.TypeRegistry; +import software.amazon.smithy.java.versionspi.ModuleVersion; import software.amazon.smithy.model.Model; import software.amazon.smithy.model.knowledge.OperationIndex; import software.amazon.smithy.model.knowledge.TopDownIndex; @@ -54,28 +57,38 @@ public static void writeForSymbol( var impl = symbol.expectProperty(ClientSymbolProperties.CLIENT_IMPL); directive.context().writerDelegator().useFileWriter(impl.getDefinitionFile(), impl.getNamespace(), writer -> { writer.pushState(new ClassSection(directive.shape(), ApplyDocumentation.NONE)); - var template = """ - final class ${impl:T} extends ${client:T} implements ${interface:T} {${?implicitErrors} - ${typeRegistry:C|}${/implicitErrors} + var template = + """ + final class ${impl:T} extends ${client:T} implements ${interface:T} {${?implicitErrors} + ${typeRegistry:C|}${/implicitErrors} - ${impl:T}(${interface:T}.Builder builder) { - super(builder); - } + private static final ${moduleVersion:T} CODEGEN_VERSION = new ${moduleVersion:T}("codegen", ${major:L}, ${minor:L}, ${patch:L}); - ${operations:C|} + ${impl:T}(${interface:T}.Builder builder) { + super(builder); + ${versionCheck:T}.check(CODEGEN_VERSION); + } - ${?implicitErrors}@Override - protected ${typeRegistryClass:T} typeRegistry() { - return TYPE_REGISTRY; - }${/implicitErrors} - } - """; + ${operations:C|} + + ${?implicitErrors}@Override + protected ${typeRegistryClass:T} typeRegistry() { + return TYPE_REGISTRY; + }${/implicitErrors} + } + """; writer.putContext("client", Client.class); writer.putContext("interface", symbol); writer.putContext("impl", impl); writer.putContext("future", CompletableFuture.class); writer.putContext("typeRegistryClass", TypeRegistry.class); writer.putContext("completionException", CompletionException.class); + writer.putContext("versionCheck", VersionCheck.class); + writer.putContext("moduleVersion", ModuleVersion.class); + var versionParts = Version.VERSION.split("\\."); + writer.putContext("major", Integer.parseInt(versionParts[0])); + writer.putContext("minor", Integer.parseInt(versionParts[1])); + writer.putContext("patch", Integer.parseInt(versionParts[2].replaceAll("[^0-9].*", ""))); var errorSymbols = getImplicitErrorSymbols( directive.symbolProvider(), directive.model(), diff --git a/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/server/generators/ServiceGenerator.java b/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/server/generators/ServiceGenerator.java index ed8d2a56c..c7f0bcf29 100644 --- a/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/server/generators/ServiceGenerator.java +++ b/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/server/generators/ServiceGenerator.java @@ -23,6 +23,8 @@ import software.amazon.smithy.java.codegen.generators.TypeRegistryGenerator; import software.amazon.smithy.java.codegen.sections.ClassSection; import software.amazon.smithy.java.codegen.writer.JavaWriter; +import software.amazon.smithy.java.core.Version; +import software.amazon.smithy.java.core.VersionCheck; import software.amazon.smithy.java.core.schema.Schema; import software.amazon.smithy.java.core.schema.SchemaIndex; import software.amazon.smithy.java.core.schema.SerializableStruct; @@ -89,6 +91,7 @@ public final class ${service:T} implements ${serviceType:T} { ${builder:C|} private static final ${schemaIndex:T} SCHEMA_INDEX = new ${generatedSchemaIndex:L}(); + private static final ${moduleVersion:T} CODEGEN_VERSION = new ${moduleVersion:T}("codegen", ${major:L}, ${minor:L}, ${patch:L}); @Override @SuppressWarnings("unchecked") @@ -126,6 +129,12 @@ public final class ${service:T} implements ${serviceType:T} { writer.putContext("typeRegistryClass", TypeRegistry.class); writer.putContext("schemaIndex", SchemaIndex.class); writer.putContext("generatedSchemaIndex", generatedSchemaIndex); + var versionParts = Version.VERSION.split("\\."); + writer.putContext("moduleVersion", + software.amazon.smithy.java.versionspi.ModuleVersion.class); + writer.putContext("major", Integer.parseInt(versionParts[0])); + writer.putContext("minor", Integer.parseInt(versionParts[1])); + writer.putContext("patch", Integer.parseInt(versionParts[2].replaceAll("[^0-9].*", ""))); var errorSymbols = getImplicitErrorSymbols( directive.symbolProvider(), directive.model(), @@ -210,6 +219,7 @@ public void run() { """ private ${service:T}(Builder builder) { ${C|} + $T.check(CODEGEN_VERSION); } """, writer.consumer(w -> { @@ -225,7 +235,8 @@ public void run() { "this.allOperations = $T.of(${#operations}${value:L}${^key.last}, ${/key.last}${/operations});", List.class); writer.popState(); - })); + }), + VersionCheck.class); } } diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 56c859235..7eb24ef74 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -17,7 +17,14 @@ dependencies { implementation(project(":logging")) } -jmh {} +jmh { + includes.addAll( + providers + .gradleProperty("jmh.includes") + .map { listOf(it) } + .orElse(emptyList()), + ) +} // Run all tests with a different locale to ensure we are not doing anything locale specific. val localeTest = diff --git a/core/src/jmh/java/software/amazon/smithy/java/core/VersionCheckBench.java b/core/src/jmh/java/software/amazon/smithy/java/core/VersionCheckBench.java new file mode 100644 index 000000000..fd0d34e61 --- /dev/null +++ b/core/src/jmh/java/software/amazon/smithy/java/core/VersionCheckBench.java @@ -0,0 +1,66 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.core; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import software.amazon.smithy.java.versionspi.ModuleVersion; + +/** + * Measures the startup cost of the version compatibility check. + * + *

In production, {@code VersionCheck.check()} runs exactly once during client + * construction. This benchmark measures the per-invocation cost to quantify + * the one-time startup impact. + */ +@State(Scope.Benchmark) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +@BenchmarkMode(Mode.AverageTime) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +@Fork(1) +public class VersionCheckBench { + + private static final ModuleVersion CODEGEN_VERSION = new ModuleVersion("benchmark", 1, 1, 0); + + @Setup(Level.Trial) + @SuppressFBWarnings(value = "LG_LOST_LOGGER_DUE_TO_WEAK_REFERENCE", justification = "Intentional for benchmark") + public void setupTrial() { + Logger.getLogger(VersionCheck.class.getName()).setLevel(java.util.logging.Level.OFF); + } + + @Setup(Level.Invocation) + public void setupInvocation() { + VersionCheck.reset(); + } + + @Benchmark + public void versionCheckEnabled() { + VersionCheck.check(CODEGEN_VERSION); + } + + @Benchmark + public void versionCheckSkipped() { + System.setProperty("smithy.java.skipVersionCheck", "true"); + try { + VersionCheck.check(CODEGEN_VERSION); + } finally { + System.clearProperty("smithy.java.skipVersionCheck"); + } + } +} diff --git a/core/src/main/java/software/amazon/smithy/java/core/IncompatibleVersionException.java b/core/src/main/java/software/amazon/smithy/java/core/IncompatibleVersionException.java new file mode 100644 index 000000000..47059f1fc --- /dev/null +++ b/core/src/main/java/software/amazon/smithy/java/core/IncompatibleVersionException.java @@ -0,0 +1,15 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.core; + +/** + * Thrown when incompatible versions of Smithy Java modules are detected on the classpath. + */ +public final class IncompatibleVersionException extends RuntimeException { + IncompatibleVersionException(String message) { + super(message); + } +} diff --git a/core/src/main/java/software/amazon/smithy/java/core/VersionCheck.java b/core/src/main/java/software/amazon/smithy/java/core/VersionCheck.java new file mode 100644 index 000000000..bd13b0014 --- /dev/null +++ b/core/src/main/java/software/amazon/smithy/java/core/VersionCheck.java @@ -0,0 +1,122 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.core; + +import java.util.ArrayList; +import java.util.ServiceLoader; +import java.util.concurrent.atomic.AtomicBoolean; +import software.amazon.smithy.java.logging.InternalLogger; +import software.amazon.smithy.java.versionspi.ModuleVersion; +import software.amazon.smithy.java.versionspi.SmithyVersionProvider; + +/** + * Validates that all Smithy Java modules on the classpath have compatible versions. + * + *

Mixing different versions of Smithy Java modules in the same application can cause + * subtle runtime errors such as missing methods, class not found exceptions, or unexpected + * behavior. This commonly happens when different dependencies pull in different versions of + * the same module transitively. This check detects such mismatches early, during client or + * server initialization, before any operation is executed. + * + *

This check uses {@link ServiceLoader} to discover all {@link SmithyVersionProvider} + * implementations on the classpath. Each Smithy Java module registers a provider via + * {@code META-INF/services}, which is correctly merged by fat JAR tools. + * + *

The check can be disabled by setting the system property + * {@code smithy.java.skipVersionCheck} to {@code true}. + */ +public final class VersionCheck { + private static final InternalLogger LOGGER = InternalLogger.getLogger(VersionCheck.class); + private static final String SKIP_PROPERTY = "smithy.java.skipVersionCheck"; + private static final AtomicBoolean CHECKED = new AtomicBoolean(false); + + private VersionCheck() {} + + /** + * Validates version compatibility of all Smithy Java modules on the classpath. + * + *

This method is safe to call multiple times; the check is performed only once. + * + * @param codegenVersion the version the code was generated against, as a {@link ModuleVersion} + * @throws IncompatibleVersionException if a version mismatch is detected + */ + public static void check(ModuleVersion codegenVersion) { + if (CHECKED.get()) { + return; + } + if (Boolean.getBoolean(SKIP_PROPERTY)) { + LOGGER.warn("Smithy Java version compatibility check is disabled via '{}'. " + + "This is not recommended and should only be used as a temporary workaround. " + + "Running with mismatched module versions may cause unexpected runtime errors.", + SKIP_PROPERTY); + CHECKED.set(true); + return; + } + + var modules = new ArrayList(); + for (var provider : ServiceLoader.load(SmithyVersionProvider.class)) { + modules.add(provider.getModuleVersion()); + } + + if (modules.isEmpty()) { + return; + } + + var errors = new ArrayList(); + + // All modules must report the same version. + var firstVersion = modules.get(0); + for (var module : modules) { + if (module.compareTo(firstVersion) != 0) { + errors.add("Version mismatch: module '" + firstVersion.moduleName() + "' has version " + + firstVersion.versionString() + " but module '" + module.moduleName() + + "' has version " + module.versionString()); + } + } + + // All module versions must be >= the codegen version. + for (var module : modules) { + if (module.compareTo(codegenVersion) < 0) { + errors.add("Module '" + module.moduleName() + "' version " + module.versionString() + + " is older than the codegen version " + codegenVersion.versionString()); + } + } + + if (!errors.isEmpty()) { + // Build a nice error message to give the end-user all the details needed + // to fix the issue. + var sb = new StringBuilder("Smithy Java version compatibility check failed:\n"); + sb.append(" Generated with version: ").append(codegenVersion.versionString()).append("\n"); + sb.append(" Modules on classpath:\n"); + for (var module : modules) { + sb.append(" - ") + .append(module.moduleName()) + .append(" = ") + .append(module.versionString()) + .append("\n"); + } + sb.append(" Issues:\n"); + for (var error : errors) { + sb.append(" - ").append(error).append("\n"); + } + sb.append(" Fix: Align all smithy-java dependencies to the same version. ") + .append("If using Gradle, consider importing the BOM: ") + .append("platform('software.amazon.smithy.java:bom:") + .append(codegenVersion.versionString()) + .append("')"); + throw new IncompatibleVersionException(sb.toString()); + } + + CHECKED.set(true); + } + + /** + * Resets the check state. Visible for testing and benchmarking only. + */ + static void reset() { + CHECKED.set(false); + } +} diff --git a/core/src/main/java/software/amazon/smithy/java/versionspi/ModuleVersion.java b/core/src/main/java/software/amazon/smithy/java/versionspi/ModuleVersion.java new file mode 100644 index 000000000..929557e31 --- /dev/null +++ b/core/src/main/java/software/amazon/smithy/java/versionspi/ModuleVersion.java @@ -0,0 +1,42 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.versionspi; + +/** + * Represents the version of a Smithy Java module. + * + * @param moduleName the module name, e.g. {@code "software.amazon.smithy.java.core"} + * @param major the major version component + * @param minor the minor version component + * @param patch the patch version component + */ +public record ModuleVersion(String moduleName, int major, int minor, int patch) implements Comparable { + + @Override + public int compareTo(ModuleVersion other) { + int c = Integer.compare(major, other.major); + if (c != 0) { + return c; + } + c = Integer.compare(minor, other.minor); + if (c != 0) { + return c; + } + return Integer.compare(patch, other.patch); + } + + /** + * Returns the version as a string, e.g. {@code "1.2.3"}. + */ + public String versionString() { + return major + "." + minor + "." + patch; + } + + @Override + public String toString() { + return moduleName + "=" + versionString(); + } +} diff --git a/core/src/main/java/software/amazon/smithy/java/versionspi/SmithyVersionProvider.java b/core/src/main/java/software/amazon/smithy/java/versionspi/SmithyVersionProvider.java new file mode 100644 index 000000000..95d40be46 --- /dev/null +++ b/core/src/main/java/software/amazon/smithy/java/versionspi/SmithyVersionProvider.java @@ -0,0 +1,20 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.versionspi; + +/** + * SPI for reporting the version of a Smithy Java module. + * + *

Each Smithy Java module provides an implementation of this interface via + * {@link java.util.ServiceLoader}. Generated clients use these providers to + * validate that all Smithy Java modules on the classpath have compatible versions. + */ +public interface SmithyVersionProvider { + /** + * Returns the module version information. + */ + ModuleVersion getModuleVersion(); +}