Skip to content

Commit fcb4e84

Browse files
authored
Add opt-in Jackson 3 support via multi-release JAR (#2783)
1 parent b9c186d commit fcb4e84

File tree

10 files changed

+614
-2
lines changed

10 files changed

+614
-2
lines changed

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,12 @@ jobs:
106106
USE_DOCKER_SERVICE: true
107107
run: ./gradlew --no-daemon test -x spotlessCheck -x spotlessApply -x spotlessJava
108108

109+
- name: Run Jackson 3 converter tests
110+
env:
111+
USER: unittest
112+
USE_DOCKER_SERVICE: false
113+
run: ./gradlew --no-daemon :temporal-sdk:jackson3Tests -x spotlessCheck -x spotlessApply -x spotlessJava
114+
109115
- name: Run virtual thread tests
110116
env:
111117
USER: unittest

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,5 @@ src/main/idls/*
1717
.project
1818
.settings
1919
.vscode/
20-
*/bin
20+
*/bin
21+
/.claude

build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ ext {
3030
// Platforms
3131
grpcVersion = '1.75.0' // [1.38.0,) Needed for io.grpc.protobuf.services.HealthStatusManager
3232
jacksonVersion = '2.15.4' // [2.9.0,)
33+
jackson3Version = '3.0.4'
3334
nexusVersion = '0.5.0-alpha'
3435
// we don't upgrade to 1.10.x because it requires kotlin 1.6. Users may use 1.10.x in their environments though.
3536
micrometerVersion = project.hasProperty("edgeDepsTest") ? '1.13.6' : '1.9.9' // [1.0.0,)

temporal-sdk/build.gradle

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,13 @@ dependencies {
3838

3939
// Temporal SDK supports Java 8 or later so to support virtual threads
4040
// we need to compile the code with Java 21 and package it in a multi-release jar.
41+
// Similarly, Jackson 3 support requires Java 17+ and is compiled separately.
4142
sourceSets {
43+
java17 {
44+
java {
45+
srcDirs = ['src/main/java17']
46+
}
47+
}
4248
java21 {
4349
java {
4450
srcDirs = ['src/main/java21']
@@ -47,9 +53,31 @@ sourceSets {
4753
}
4854

4955
dependencies {
56+
// The java17 source set needs protobuf and other main dependencies to compile. We pass
57+
// the main compile classpath as files rather than extending from api/implementation
58+
// configurations, because extendsFrom triggers Gradle's variant-aware resolution which
59+
// rejects project dependencies when the java17 target JVM (17) differs from the resolved
60+
// project's JVM compatibility (e.g. 21+ on CI edge runners).
61+
java17Implementation files(sourceSets.main.output.classesDirs) { builtBy compileJava }
62+
java17Implementation files({ sourceSets.main.compileClasspath })
63+
java17CompileOnly "tools.jackson.core:jackson-databind:$jackson3Version"
64+
5065
java21Implementation files(sourceSets.main.output.classesDirs) { builtBy compileJava }
5166
}
5267

68+
tasks.named('compileJava17Java') {
69+
// Gradle toolchains are too strict and require the JDK to match the specified version exactly.
70+
// This is a workaround to use a JDK 17+ compiler.
71+
//
72+
// See also: https://github.com/gradle/gradle/issues/16256
73+
if (!JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_17)) {
74+
javaCompiler = javaToolchains.compilerFor {
75+
languageVersion = JavaLanguageVersion.of(17)
76+
}
77+
}
78+
options.release = 17
79+
}
80+
5381
tasks.named('compileJava21Java') {
5482
// Gradle toolchains are too strict and require the JDK to match the specified version exactly.
5583
// This is a workaround to use a JDK 21+ compiler.
@@ -64,6 +92,9 @@ tasks.named('compileJava21Java') {
6492
}
6593

6694
jar {
95+
into('META-INF/versions/17') {
96+
from sourceSets.java17.output
97+
}
6798
into('META-INF/versions/21') {
6899
from sourceSets.java21.output
69100
}
@@ -72,6 +103,27 @@ jar {
72103
)
73104
}
74105

106+
// Publish Jackson 3 as an optional dependency so users can opt-in
107+
afterEvaluate {
108+
publishing {
109+
publications {
110+
mavenJava {
111+
pom.withXml {
112+
def depsNode = asNode()['dependencies'][0]
113+
if (depsNode == null) {
114+
depsNode = asNode().appendNode('dependencies')
115+
}
116+
def dep = depsNode.appendNode('dependency')
117+
dep.appendNode('groupId', 'tools.jackson.core')
118+
dep.appendNode('artifactId', 'jackson-databind')
119+
dep.appendNode('version', '[' + jackson3Version + ',)')
120+
dep.appendNode('optional', 'true')
121+
}
122+
}
123+
}
124+
}
125+
}
126+
75127
task registerNamespace(type: JavaExec) {
76128
getMainClass().set('io.temporal.internal.docker.RegisterTestNamespace')
77129
classpath = sourceSets.test.runtimeClasspath
@@ -85,6 +137,22 @@ test {
85137
}
86138
}
87139

140+
// On Java 17+, prepend java17 classes to all test classpaths so that Class.forName finds
141+
// the real Jackson3JsonPayloadConverter instead of the Java 8 stub. This lets us test
142+
// the present-java17-but-absent-jackson3 behavior (NoClassDefFoundError) in the same
143+
// test that tests the Java 8 stub behavior (UnsupportedOperationException).
144+
tasks.withType(Test).configureEach {
145+
if (JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_17)) {
146+
dependsOn compileJava17Java
147+
}
148+
doFirst {
149+
int launcherMajorVersion = javaLauncher.get().metadata.languageVersion.asInt()
150+
if (launcherMajorVersion >= 17) {
151+
classpath = files(sourceSets.java17.output.classesDirs) + classpath
152+
}
153+
}
154+
}
155+
88156
task testResourceIndependent(type: Test) {
89157
useJUnit {
90158
includeCategories 'io.temporal.worker.IndependentResourceBasedTests'
@@ -124,6 +192,36 @@ testing {
124192
}
125193
}
126194

195+
jackson3Tests(JvmTestSuite) {
196+
dependencies {
197+
// java17 output must come before project() (added by configureEach) so that
198+
// the compiler and runtime see the real Jackson3JsonPayloadConverter — which
199+
// has a wider API than the Java 8 stub (newDefaultJsonMapper, JsonMapper
200+
// constructor) because the stub can't reference Jackson 3 types.
201+
implementation files(sourceSets.java17.output.classesDirs) { builtBy compileJava17Java }
202+
implementation "tools.jackson.core:jackson-databind:$jackson3Version"
203+
}
204+
targets {
205+
all {
206+
testTask.configure {
207+
javaLauncher = javaToolchains.launcherFor {
208+
languageVersion = JavaLanguageVersion.of(17)
209+
}
210+
shouldRunAfter(test)
211+
}
212+
}
213+
}
214+
}
215+
216+
// Unlike virtualThreadTests, jackson3Tests source directly imports Jackson 3 types
217+
// and java17 classes, so the compile task also needs a Java 17 compiler (not just
218+
// the test launcher).
219+
tasks.named('compileJackson3TestsJava') {
220+
javaCompiler = javaToolchains.compilerFor {
221+
languageVersion = JavaLanguageVersion.of(17)
222+
}
223+
}
224+
127225
virtualThreadTests(JvmTestSuite) {
128226
targets {
129227
all {
@@ -164,5 +262,6 @@ testing {
164262
}
165263

166264
tasks.named('check') {
265+
dependsOn(testing.suites.jackson3Tests)
167266
dependsOn(testing.suites.virtualThreadTests)
168267
}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
package io.temporal.common.converter;
2+
3+
import static org.junit.Assert.assertEquals;
4+
import static org.junit.Assert.assertTrue;
5+
6+
import com.fasterxml.jackson.databind.ObjectMapper;
7+
import io.temporal.api.common.v1.Payload;
8+
import java.time.Instant;
9+
import java.util.Objects;
10+
import java.util.Optional;
11+
import org.junit.After;
12+
import org.junit.Test;
13+
import tools.jackson.databind.json.JsonMapper;
14+
15+
public class Jackson3JsonPayloadConverterTest {
16+
17+
@After
18+
public void resetJackson3Delegate() {
19+
JacksonJsonPayloadConverter.setDefaultAsJackson3(false, false);
20+
}
21+
22+
@Test
23+
public void testSimple() {
24+
Jackson3JsonPayloadConverter converter = new Jackson3JsonPayloadConverter();
25+
TestPayload payload = new TestPayload(1L, Instant.now(), "myPayload");
26+
Optional<Payload> data = converter.toData(payload);
27+
assertTrue(data.isPresent());
28+
29+
// Jackson 3 native defaults sort fields alphabetically (id, name, timestamp)
30+
// unlike jackson2Compat which preserves declaration order (id, timestamp, name)
31+
String json = data.get().getData().toStringUtf8();
32+
assertTrue(
33+
"Expected alphabetical field order (Jackson 3 native), got: " + json,
34+
json.indexOf("\"name\"") < json.indexOf("\"timestamp\""));
35+
36+
TestPayload converted = converter.fromData(data.get(), TestPayload.class, TestPayload.class);
37+
assertEquals(payload, converted);
38+
}
39+
40+
@Test
41+
public void testSimpleJackson2Compat() {
42+
Jackson3JsonPayloadConverter converter = new Jackson3JsonPayloadConverter(true);
43+
TestPayload payload = new TestPayload(1L, Instant.now(), "myPayload");
44+
Optional<Payload> data = converter.toData(payload);
45+
assertTrue(data.isPresent());
46+
47+
// jackson2Compat preserves declaration order (id, timestamp, name)
48+
// unlike Jackson 3 native which sorts alphabetically (id, name, timestamp)
49+
String json = data.get().getData().toStringUtf8();
50+
assertTrue(
51+
"Expected declaration field order (jackson2Compat), got: " + json,
52+
json.indexOf("\"timestamp\"") < json.indexOf("\"name\""));
53+
54+
TestPayload converted = converter.fromData(data.get(), TestPayload.class, TestPayload.class);
55+
assertEquals(payload, converted);
56+
}
57+
58+
@Test
59+
public void testCustomJsonMapper() {
60+
JsonMapper mapper =
61+
Jackson3JsonPayloadConverter.newDefaultJsonMapper(false)
62+
.rebuild()
63+
.enable(tools.jackson.databind.SerializationFeature.INDENT_OUTPUT)
64+
.build();
65+
Jackson3JsonPayloadConverter converter = new Jackson3JsonPayloadConverter(mapper);
66+
TestPayload payload = new TestPayload(1L, Instant.now(), "test");
67+
Optional<Payload> data = converter.toData(payload);
68+
assertTrue(data.isPresent());
69+
String json = data.get().getData().toStringUtf8();
70+
assertTrue("Expected pretty-printed JSON", json.contains("\n"));
71+
}
72+
73+
@Test
74+
public void testEncodingType() {
75+
Jackson3JsonPayloadConverter converter = new Jackson3JsonPayloadConverter();
76+
assertEquals("json/plain", converter.getEncodingType());
77+
}
78+
79+
@Test
80+
public void testWireCompatibilityBetweenJackson2AndJackson3() {
81+
JacksonJsonPayloadConverter jackson2 = new JacksonJsonPayloadConverter();
82+
Jackson3JsonPayloadConverter jackson3 = new Jackson3JsonPayloadConverter(true);
83+
84+
TestPayload payload = new TestPayload(42L, Instant.parse("2024-01-15T10:30:00Z"), "wireTest");
85+
86+
// Jackson 2 serialized -> Jackson 3 deserialized
87+
Optional<Payload> data2 = jackson2.toData(payload);
88+
assertTrue(data2.isPresent());
89+
assertEquals(payload, jackson3.fromData(data2.get(), TestPayload.class, TestPayload.class));
90+
91+
// Jackson 3 serialized -> Jackson 2 deserialized
92+
Optional<Payload> data3 = jackson3.toData(payload);
93+
assertTrue(data3.isPresent());
94+
assertEquals(payload, jackson2.fromData(data3.get(), TestPayload.class, TestPayload.class));
95+
}
96+
97+
@Test
98+
public void testSetDefaultAsJackson3() {
99+
JacksonJsonPayloadConverter.setDefaultAsJackson3(true, false);
100+
101+
Optional<Payload> data =
102+
GlobalDataConverter.get().toPayload(new TestPayload(1L, Instant.now(), "delegated"));
103+
assertTrue(data.isPresent());
104+
105+
// Alphabetical field order proves Jackson 3 native is being used
106+
String json = data.get().getData().toStringUtf8();
107+
assertTrue(
108+
"Expected alphabetical field order (Jackson 3 native), got: " + json,
109+
json.indexOf("\"name\"") < json.indexOf("\"timestamp\""));
110+
}
111+
112+
@Test
113+
public void testSetDefaultAsJackson3WithCompat() {
114+
JacksonJsonPayloadConverter.setDefaultAsJackson3(true, true);
115+
116+
Optional<Payload> data =
117+
GlobalDataConverter.get().toPayload(new TestPayload(1L, Instant.now(), "delegated-compat"));
118+
assertTrue(data.isPresent());
119+
120+
// Declaration field order proves Jackson 3 with jackson2Compat is being used
121+
String json = data.get().getData().toStringUtf8();
122+
assertTrue(
123+
"Expected declaration field order (jackson2Compat), got: " + json,
124+
json.indexOf("\"timestamp\"") < json.indexOf("\"name\""));
125+
}
126+
127+
@Test
128+
public void testExplicitObjectMapperIgnoresJackson3Delegate() {
129+
// Enable Jackson 3 native globally (which sorts fields alphabetically)
130+
JacksonJsonPayloadConverter.setDefaultAsJackson3(true, false);
131+
132+
// Converter created with explicit ObjectMapper should NOT delegate to Jackson 3
133+
ObjectMapper mapper = JacksonJsonPayloadConverter.newDefaultObjectMapper();
134+
JacksonJsonPayloadConverter converter = new JacksonJsonPayloadConverter(mapper);
135+
136+
TestPayload payload = new TestPayload(1L, Instant.now(), "explicit");
137+
Optional<Payload> data = converter.toData(payload);
138+
assertTrue(data.isPresent());
139+
140+
// Declaration field order proves Jackson 2 is still being used, not the Jackson 3 delegate
141+
String json = data.get().getData().toStringUtf8();
142+
assertTrue(
143+
"Expected declaration field order (Jackson 2), got: " + json,
144+
json.indexOf("\"timestamp\"") < json.indexOf("\"name\""));
145+
}
146+
147+
static class TestPayload {
148+
private long id;
149+
private Instant timestamp;
150+
private String name;
151+
152+
public TestPayload() {}
153+
154+
TestPayload(long id, Instant timestamp, String name) {
155+
this.id = id;
156+
this.timestamp = timestamp;
157+
this.name = name;
158+
}
159+
160+
public long getId() {
161+
return id;
162+
}
163+
164+
public void setId(long id) {
165+
this.id = id;
166+
}
167+
168+
public Instant getTimestamp() {
169+
return timestamp;
170+
}
171+
172+
public void setTimestamp(Instant timestamp) {
173+
this.timestamp = timestamp;
174+
}
175+
176+
public String getName() {
177+
return name;
178+
}
179+
180+
public void setName(String name) {
181+
this.name = name;
182+
}
183+
184+
@Override
185+
public boolean equals(Object o) {
186+
if (this == o) {
187+
return true;
188+
}
189+
if (o == null || getClass() != o.getClass()) {
190+
return false;
191+
}
192+
TestPayload that = (TestPayload) o;
193+
return id == that.id
194+
&& Objects.equals(timestamp, that.timestamp)
195+
&& Objects.equals(name, that.name);
196+
}
197+
198+
@Override
199+
public int hashCode() {
200+
return Objects.hash(id, timestamp, name);
201+
}
202+
203+
@Override
204+
public String toString() {
205+
return "TestPayload{"
206+
+ "id="
207+
+ id
208+
+ ", timestamp="
209+
+ timestamp
210+
+ ", name='"
211+
+ name
212+
+ '\''
213+
+ '}';
214+
}
215+
}
216+
}

0 commit comments

Comments
 (0)