Skip to content

Commit 1a466b7

Browse files
authored
Add a mechanism to avoid mixing versions for a client (#1167)
* Add a mechanism to avoid mixing versions for a client * Run spotlessApply * Move the version validation to the Schemas class * Make sure that the class & errors provide teh needed details to troubleshoot * Run spotlessApply to fix formatting
1 parent 63dee61 commit 1a466b7

10 files changed

Lines changed: 438 additions & 15 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import org.gradle.api.DefaultTask
2+
import org.gradle.api.tasks.Input
3+
import org.gradle.api.tasks.OutputDirectory
4+
import org.gradle.api.tasks.TaskAction
5+
import java.io.File
6+
7+
/**
8+
* Gradle task that generates a SmithyVersionProvider SPI implementation for a module.
9+
*
10+
* When [generateInterface] is true, also generates the interface and record classes
11+
* for modules that don't have them on their compile classpath (i.e., modules that
12+
* don't depend on core).
13+
*/
14+
abstract class GenerateVersionProviderTask : DefaultTask() {
15+
16+
@get:Input
17+
var moduleName: String = ""
18+
19+
@get:Input
20+
var moduleVersion: String = ""
21+
22+
@get:Input
23+
var generateInterface: Boolean = false
24+
25+
@get:OutputDirectory
26+
var outputDir: File = project.layout.buildDirectory.dir("generated/version-provider").get().asFile
27+
28+
companion object {
29+
private const val PACKAGE = "software.amazon.smithy.java.versionspi"
30+
private const val INTERFACE_NAME = "SmithyVersionProvider"
31+
private const val RECORD_NAME = "ModuleVersion"
32+
private const val IMPL_NAME = "GeneratedVersionProvider"
33+
}
34+
35+
@TaskAction
36+
fun generate() {
37+
val parts = moduleVersion.split(".")
38+
val major = parts.getOrElse(0) { "0" }
39+
val minor = parts.getOrElse(1) { "0" }
40+
val patch = parts.getOrElse(2) { "0" }.replace(Regex("[^0-9].*"), "")
41+
42+
val packageDir = File(outputDir, "java/${PACKAGE.replace('.', '/')}")
43+
packageDir.mkdirs()
44+
45+
if (generateInterface) {
46+
File(packageDir, "$INTERFACE_NAME.java").writeText(
47+
"""
48+
|package $PACKAGE;
49+
|
50+
|public interface $INTERFACE_NAME {
51+
| $RECORD_NAME getModuleVersion();
52+
|}
53+
""".trimMargin()
54+
)
55+
56+
File(packageDir, "$RECORD_NAME.java").writeText(
57+
"""
58+
|package $PACKAGE;
59+
|
60+
|public record $RECORD_NAME(String moduleName, int major, int minor, int patch) implements Comparable<$RECORD_NAME> {
61+
| @Override
62+
| public int compareTo($RECORD_NAME other) {
63+
| int c = Integer.compare(major, other.major);
64+
| if (c != 0) {
65+
| return c;
66+
| }
67+
| c = Integer.compare(minor, other.minor);
68+
| if (c != 0) {
69+
| return c;
70+
| }
71+
| return Integer.compare(patch, other.patch);
72+
| }
73+
|
74+
| public String versionString() {
75+
| return major + "." + minor + "." + patch;
76+
| }
77+
|
78+
| @Override
79+
| public String toString() {
80+
| return moduleName + "=" + versionString();
81+
| }
82+
|}
83+
""".trimMargin()
84+
)
85+
}
86+
87+
// Always generate the implementation
88+
File(packageDir, "$IMPL_NAME.java").writeText(
89+
"""
90+
|package $PACKAGE;
91+
|
92+
|public final class $IMPL_NAME implements $INTERFACE_NAME {
93+
| private static final $RECORD_NAME VERSION = new $RECORD_NAME("$moduleName", $major, $minor, $patch);
94+
|
95+
| @Override
96+
| public $RECORD_NAME getModuleVersion() {
97+
| return VERSION;
98+
| }
99+
|}
100+
""".trimMargin()
101+
)
102+
103+
// Always generate META-INF/services file
104+
val servicesDir = File(outputDir, "resources/META-INF/services")
105+
servicesDir.mkdirs()
106+
File(servicesDir, "$PACKAGE.$INTERFACE_NAME").writeText(
107+
"$PACKAGE.$IMPL_NAME\n"
108+
)
109+
}
110+
}

buildSrc/src/main/kotlin/smithy-java.module-conventions.gradle.kts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,23 @@ afterEvaluate {
5050
attributes(mapOf("Automatic-Module-Name" to moduleName))
5151
}
5252
}
53+
54+
// Generate a SmithyVersionProvider SPI implementation for this module.
55+
if (!project.plugins.hasPlugin("software.amazon.smithy.gradle.smithy-jar")) {
56+
val dependsOnCore = project.path == ":core" || project.configurations.getByName("compileClasspath")
57+
.resolvedConfiguration.resolvedArtifacts.any {
58+
it.moduleVersion.id.group == "software.amazon.smithy.java" && it.moduleVersion.id.name == "core"
59+
}
60+
val generateVersionProvider = tasks.register<GenerateVersionProviderTask>("generateVersionProvider") {
61+
this.moduleName = moduleName
62+
this.moduleVersion = smithyJavaVersion
63+
this.generateInterface = !dependsOnCore
64+
}
65+
sourceSets["main"].java.srcDir(generateVersionProvider.map { it.outputDir.resolve("java") })
66+
sourceSets["main"].resources.srcDir(generateVersionProvider.map { it.outputDir.resolve("resources") })
67+
tasks.named("compileJava") { dependsOn(generateVersionProvider) }
68+
tasks.named("processResources") { dependsOn(generateVersionProvider) }
69+
}
5370
}
5471

5572
// Always run javadoc after build.

codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/client/generators/ClientImplementationGenerator.java

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@
2727
import software.amazon.smithy.java.codegen.sections.ApplyDocumentation;
2828
import software.amazon.smithy.java.codegen.sections.ClassSection;
2929
import software.amazon.smithy.java.codegen.writer.JavaWriter;
30+
import software.amazon.smithy.java.core.Version;
31+
import software.amazon.smithy.java.core.VersionCheck;
3032
import software.amazon.smithy.java.core.serde.TypeRegistry;
33+
import software.amazon.smithy.java.versionspi.ModuleVersion;
3134
import software.amazon.smithy.model.Model;
3235
import software.amazon.smithy.model.knowledge.OperationIndex;
3336
import software.amazon.smithy.model.knowledge.TopDownIndex;
@@ -54,28 +57,38 @@ public static void writeForSymbol(
5457
var impl = symbol.expectProperty(ClientSymbolProperties.CLIENT_IMPL);
5558
directive.context().writerDelegator().useFileWriter(impl.getDefinitionFile(), impl.getNamespace(), writer -> {
5659
writer.pushState(new ClassSection(directive.shape(), ApplyDocumentation.NONE));
57-
var template = """
58-
final class ${impl:T} extends ${client:T} implements ${interface:T} {${?implicitErrors}
59-
${typeRegistry:C|}${/implicitErrors}
60+
var template =
61+
"""
62+
final class ${impl:T} extends ${client:T} implements ${interface:T} {${?implicitErrors}
63+
${typeRegistry:C|}${/implicitErrors}
6064
61-
${impl:T}(${interface:T}.Builder builder) {
62-
super(builder);
63-
}
65+
private static final ${moduleVersion:T} CODEGEN_VERSION = new ${moduleVersion:T}("codegen", ${major:L}, ${minor:L}, ${patch:L});
6466
65-
${operations:C|}
67+
${impl:T}(${interface:T}.Builder builder) {
68+
super(builder);
69+
${versionCheck:T}.check(CODEGEN_VERSION);
70+
}
6671
67-
${?implicitErrors}@Override
68-
protected ${typeRegistryClass:T} typeRegistry() {
69-
return TYPE_REGISTRY;
70-
}${/implicitErrors}
71-
}
72-
""";
72+
${operations:C|}
73+
74+
${?implicitErrors}@Override
75+
protected ${typeRegistryClass:T} typeRegistry() {
76+
return TYPE_REGISTRY;
77+
}${/implicitErrors}
78+
}
79+
""";
7380
writer.putContext("client", Client.class);
7481
writer.putContext("interface", symbol);
7582
writer.putContext("impl", impl);
7683
writer.putContext("future", CompletableFuture.class);
7784
writer.putContext("typeRegistryClass", TypeRegistry.class);
7885
writer.putContext("completionException", CompletionException.class);
86+
writer.putContext("versionCheck", VersionCheck.class);
87+
writer.putContext("moduleVersion", ModuleVersion.class);
88+
var versionParts = Version.VERSION.split("\\.");
89+
writer.putContext("major", Integer.parseInt(versionParts[0]));
90+
writer.putContext("minor", Integer.parseInt(versionParts[1]));
91+
writer.putContext("patch", Integer.parseInt(versionParts[2].replaceAll("[^0-9].*", "")));
7992
var errorSymbols = getImplicitErrorSymbols(
8093
directive.symbolProvider(),
8194
directive.model(),

codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/server/generators/ServiceGenerator.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
import software.amazon.smithy.java.codegen.generators.TypeRegistryGenerator;
2424
import software.amazon.smithy.java.codegen.sections.ClassSection;
2525
import software.amazon.smithy.java.codegen.writer.JavaWriter;
26+
import software.amazon.smithy.java.core.Version;
27+
import software.amazon.smithy.java.core.VersionCheck;
2628
import software.amazon.smithy.java.core.schema.Schema;
2729
import software.amazon.smithy.java.core.schema.SchemaIndex;
2830
import software.amazon.smithy.java.core.schema.SerializableStruct;
@@ -89,6 +91,7 @@ public final class ${service:T} implements ${serviceType:T} {
8991
${builder:C|}
9092
9193
private static final ${schemaIndex:T} SCHEMA_INDEX = new ${generatedSchemaIndex:L}();
94+
private static final ${moduleVersion:T} CODEGEN_VERSION = new ${moduleVersion:T}("codegen", ${major:L}, ${minor:L}, ${patch:L});
9295
9396
@Override
9497
@SuppressWarnings("unchecked")
@@ -126,6 +129,12 @@ public final class ${service:T} implements ${serviceType:T} {
126129
writer.putContext("typeRegistryClass", TypeRegistry.class);
127130
writer.putContext("schemaIndex", SchemaIndex.class);
128131
writer.putContext("generatedSchemaIndex", generatedSchemaIndex);
132+
var versionParts = Version.VERSION.split("\\.");
133+
writer.putContext("moduleVersion",
134+
software.amazon.smithy.java.versionspi.ModuleVersion.class);
135+
writer.putContext("major", Integer.parseInt(versionParts[0]));
136+
writer.putContext("minor", Integer.parseInt(versionParts[1]));
137+
writer.putContext("patch", Integer.parseInt(versionParts[2].replaceAll("[^0-9].*", "")));
129138
var errorSymbols = getImplicitErrorSymbols(
130139
directive.symbolProvider(),
131140
directive.model(),
@@ -210,6 +219,7 @@ public void run() {
210219
"""
211220
private ${service:T}(Builder builder) {
212221
${C|}
222+
$T.check(CODEGEN_VERSION);
213223
}
214224
""",
215225
writer.consumer(w -> {
@@ -225,7 +235,8 @@ public void run() {
225235
"this.allOperations = $T.of(${#operations}${value:L}${^key.last}, ${/key.last}${/operations});",
226236
List.class);
227237
writer.popState();
228-
}));
238+
}),
239+
VersionCheck.class);
229240
}
230241
}
231242

core/build.gradle.kts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,14 @@ dependencies {
1717
implementation(project(":logging"))
1818
}
1919

20-
jmh {}
20+
jmh {
21+
includes.addAll(
22+
providers
23+
.gradleProperty("jmh.includes")
24+
.map { listOf(it) }
25+
.orElse(emptyList()),
26+
)
27+
}
2128

2229
// Run all tests with a different locale to ensure we are not doing anything locale specific.
2330
val localeTest =
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package software.amazon.smithy.java.core;
7+
8+
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
9+
import java.util.concurrent.TimeUnit;
10+
import java.util.logging.Logger;
11+
import org.openjdk.jmh.annotations.Benchmark;
12+
import org.openjdk.jmh.annotations.BenchmarkMode;
13+
import org.openjdk.jmh.annotations.Fork;
14+
import org.openjdk.jmh.annotations.Level;
15+
import org.openjdk.jmh.annotations.Measurement;
16+
import org.openjdk.jmh.annotations.Mode;
17+
import org.openjdk.jmh.annotations.OutputTimeUnit;
18+
import org.openjdk.jmh.annotations.Scope;
19+
import org.openjdk.jmh.annotations.Setup;
20+
import org.openjdk.jmh.annotations.State;
21+
import org.openjdk.jmh.annotations.Warmup;
22+
import software.amazon.smithy.java.versionspi.ModuleVersion;
23+
24+
/**
25+
* Measures the startup cost of the version compatibility check.
26+
*
27+
* <p>In production, {@code VersionCheck.check()} runs exactly once during client
28+
* construction. This benchmark measures the per-invocation cost to quantify
29+
* the one-time startup impact.
30+
*/
31+
@State(Scope.Benchmark)
32+
@OutputTimeUnit(TimeUnit.MICROSECONDS)
33+
@BenchmarkMode(Mode.AverageTime)
34+
@Warmup(iterations = 3, time = 1)
35+
@Measurement(iterations = 5, time = 1)
36+
@Fork(1)
37+
public class VersionCheckBench {
38+
39+
private static final ModuleVersion CODEGEN_VERSION = new ModuleVersion("benchmark", 1, 1, 0);
40+
41+
@Setup(Level.Trial)
42+
@SuppressFBWarnings(value = "LG_LOST_LOGGER_DUE_TO_WEAK_REFERENCE", justification = "Intentional for benchmark")
43+
public void setupTrial() {
44+
Logger.getLogger(VersionCheck.class.getName()).setLevel(java.util.logging.Level.OFF);
45+
}
46+
47+
@Setup(Level.Invocation)
48+
public void setupInvocation() {
49+
VersionCheck.reset();
50+
}
51+
52+
@Benchmark
53+
public void versionCheckEnabled() {
54+
VersionCheck.check(CODEGEN_VERSION);
55+
}
56+
57+
@Benchmark
58+
public void versionCheckSkipped() {
59+
System.setProperty("smithy.java.skipVersionCheck", "true");
60+
try {
61+
VersionCheck.check(CODEGEN_VERSION);
62+
} finally {
63+
System.clearProperty("smithy.java.skipVersionCheck");
64+
}
65+
}
66+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package software.amazon.smithy.java.core;
7+
8+
/**
9+
* Thrown when incompatible versions of Smithy Java modules are detected on the classpath.
10+
*/
11+
public final class IncompatibleVersionException extends RuntimeException {
12+
IncompatibleVersionException(String message) {
13+
super(message);
14+
}
15+
}

0 commit comments

Comments
 (0)