Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ jobs:
USE_DOCKER_SERVICE: true
run: ./gradlew --no-daemon test -x spotlessCheck -x spotlessApply -x spotlessJava

- name: Run Jackson 3 converter tests
env:
USER: unittest
USE_DOCKER_SERVICE: false
run: ./gradlew --no-daemon :temporal-sdk:jackson3Tests -x spotlessCheck -x spotlessApply -x spotlessJava

- name: Run virtual thread tests
env:
USER: unittest
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ src/main/idls/*
.project
.settings
.vscode/
*/bin
*/bin
/.claude
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ ext {
// Platforms
grpcVersion = '1.75.0' // [1.38.0,) Needed for io.grpc.protobuf.services.HealthStatusManager
jacksonVersion = '2.15.4' // [2.9.0,)
jackson3Version = '3.0.4'
nexusVersion = '0.4.0-alpha'
// we don't upgrade to 1.10.x because it requires kotlin 1.6. Users may use 1.10.x in their environments though.
micrometerVersion = project.hasProperty("edgeDepsTest") ? '1.13.6' : '1.9.9' // [1.0.0,)
Expand Down
99 changes: 99 additions & 0 deletions temporal-sdk/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,13 @@ dependencies {

// Temporal SDK supports Java 8 or later so to support virtual threads
// we need to compile the code with Java 21 and package it in a multi-release jar.
// Similarly, Jackson 3 support requires Java 17+ and is compiled separately.
sourceSets {
java17 {
java {
srcDirs = ['src/main/java17']
}
}
java21 {
java {
srcDirs = ['src/main/java21']
Expand All @@ -47,9 +53,31 @@ sourceSets {
}

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

java21Implementation files(sourceSets.main.output.classesDirs) { builtBy compileJava }
}

tasks.named('compileJava17Java') {
// Gradle toolchains are too strict and require the JDK to match the specified version exactly.
// This is a workaround to use a JDK 17+ compiler.
//
// See also: https://github.com/gradle/gradle/issues/16256
if (!JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_17)) {
javaCompiler = javaToolchains.compilerFor {
languageVersion = JavaLanguageVersion.of(17)
}
}
options.release = 17
}

tasks.named('compileJava21Java') {
// Gradle toolchains are too strict and require the JDK to match the specified version exactly.
// This is a workaround to use a JDK 21+ compiler.
Expand All @@ -64,6 +92,9 @@ tasks.named('compileJava21Java') {
}

jar {
into('META-INF/versions/17') {
from sourceSets.java17.output
}
into('META-INF/versions/21') {
from sourceSets.java21.output
}
Expand All @@ -72,6 +103,27 @@ jar {
)
}

// Publish Jackson 3 as an optional dependency so users can opt-in
afterEvaluate {
publishing {
publications {
mavenJava {
pom.withXml {
def depsNode = asNode()['dependencies'][0]
if (depsNode == null) {
depsNode = asNode().appendNode('dependencies')
}
def dep = depsNode.appendNode('dependency')
dep.appendNode('groupId', 'tools.jackson.core')
dep.appendNode('artifactId', 'jackson-databind')
dep.appendNode('version', '[' + jackson3Version + ',)')
dep.appendNode('optional', 'true')
}
}
}
}
}

task registerNamespace(type: JavaExec) {
getMainClass().set('io.temporal.internal.docker.RegisterTestNamespace')
classpath = sourceSets.test.runtimeClasspath
Expand All @@ -85,6 +137,22 @@ test {
}
}

// On Java 17+, prepend java17 classes to all test classpaths so that Class.forName finds
// the real Jackson3JsonPayloadConverter instead of the Java 8 stub. This lets us test
// the present-java17-but-absent-jackson3 behavior (NoClassDefFoundError) in the same
// test that tests the Java 8 stub behavior (UnsupportedOperationException).
tasks.withType(Test).configureEach {
if (JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_17)) {
dependsOn compileJava17Java
}
doFirst {
int launcherMajorVersion = javaLauncher.get().metadata.languageVersion.asInt()
if (launcherMajorVersion >= 17) {
classpath = files(sourceSets.java17.output.classesDirs) + classpath
}
}
}

task testResourceIndependent(type: Test) {
useJUnit {
includeCategories 'io.temporal.worker.IndependentResourceBasedTests'
Expand Down Expand Up @@ -124,6 +192,36 @@ testing {
}
}

jackson3Tests(JvmTestSuite) {
dependencies {
// java17 output must come before project() (added by configureEach) so that
// the compiler and runtime see the real Jackson3JsonPayloadConverter — which
// has a wider API than the Java 8 stub (newDefaultJsonMapper, JsonMapper
// constructor) because the stub can't reference Jackson 3 types.
implementation files(sourceSets.java17.output.classesDirs) { builtBy compileJava17Java }
implementation "tools.jackson.core:jackson-databind:$jackson3Version"
}
targets {
all {
testTask.configure {
javaLauncher = javaToolchains.launcherFor {
languageVersion = JavaLanguageVersion.of(17)
}
shouldRunAfter(test)
}
}
}
}

// Unlike virtualThreadTests, jackson3Tests source directly imports Jackson 3 types
// and java17 classes, so the compile task also needs a Java 17 compiler (not just
// the test launcher).
tasks.named('compileJackson3TestsJava') {
javaCompiler = javaToolchains.compilerFor {
languageVersion = JavaLanguageVersion.of(17)
}
}

virtualThreadTests(JvmTestSuite) {
targets {
all {
Expand Down Expand Up @@ -164,5 +262,6 @@ testing {
}

tasks.named('check') {
dependsOn(testing.suites.jackson3Tests)
dependsOn(testing.suites.virtualThreadTests)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
package io.temporal.common.converter;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;

import com.fasterxml.jackson.databind.ObjectMapper;
import io.temporal.api.common.v1.Payload;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
import org.junit.After;
import org.junit.Test;
import tools.jackson.databind.json.JsonMapper;

public class Jackson3JsonPayloadConverterTest {

@After
public void resetJackson3Delegate() {
JacksonJsonPayloadConverter.setDefaultAsJackson3(false, false);
}

@Test
public void testSimple() {
Jackson3JsonPayloadConverter converter = new Jackson3JsonPayloadConverter();
TestPayload payload = new TestPayload(1L, Instant.now(), "myPayload");
Optional<Payload> data = converter.toData(payload);
assertTrue(data.isPresent());

// Jackson 3 native defaults sort fields alphabetically (id, name, timestamp)
// unlike jackson2Compat which preserves declaration order (id, timestamp, name)
String json = data.get().getData().toStringUtf8();
assertTrue(
"Expected alphabetical field order (Jackson 3 native), got: " + json,
json.indexOf("\"name\"") < json.indexOf("\"timestamp\""));

TestPayload converted = converter.fromData(data.get(), TestPayload.class, TestPayload.class);
assertEquals(payload, converted);
}

@Test
public void testSimpleJackson2Compat() {
Jackson3JsonPayloadConverter converter = new Jackson3JsonPayloadConverter(true);
TestPayload payload = new TestPayload(1L, Instant.now(), "myPayload");
Optional<Payload> data = converter.toData(payload);
assertTrue(data.isPresent());

// jackson2Compat preserves declaration order (id, timestamp, name)
// unlike Jackson 3 native which sorts alphabetically (id, name, timestamp)
String json = data.get().getData().toStringUtf8();
assertTrue(
"Expected declaration field order (jackson2Compat), got: " + json,
json.indexOf("\"timestamp\"") < json.indexOf("\"name\""));

TestPayload converted = converter.fromData(data.get(), TestPayload.class, TestPayload.class);
assertEquals(payload, converted);
}

@Test
public void testCustomJsonMapper() {
JsonMapper mapper =
Jackson3JsonPayloadConverter.newDefaultJsonMapper(false)
.rebuild()
.enable(tools.jackson.databind.SerializationFeature.INDENT_OUTPUT)
.build();
Jackson3JsonPayloadConverter converter = new Jackson3JsonPayloadConverter(mapper);
TestPayload payload = new TestPayload(1L, Instant.now(), "test");
Optional<Payload> data = converter.toData(payload);
assertTrue(data.isPresent());
String json = data.get().getData().toStringUtf8();
assertTrue("Expected pretty-printed JSON", json.contains("\n"));
}

@Test
public void testEncodingType() {
Jackson3JsonPayloadConverter converter = new Jackson3JsonPayloadConverter();
assertEquals("json/plain", converter.getEncodingType());
}

@Test
public void testWireCompatibilityBetweenJackson2AndJackson3() {
JacksonJsonPayloadConverter jackson2 = new JacksonJsonPayloadConverter();
Jackson3JsonPayloadConverter jackson3 = new Jackson3JsonPayloadConverter(true);

TestPayload payload = new TestPayload(42L, Instant.parse("2024-01-15T10:30:00Z"), "wireTest");

// Jackson 2 serialized -> Jackson 3 deserialized
Optional<Payload> data2 = jackson2.toData(payload);
assertTrue(data2.isPresent());
assertEquals(payload, jackson3.fromData(data2.get(), TestPayload.class, TestPayload.class));

// Jackson 3 serialized -> Jackson 2 deserialized
Optional<Payload> data3 = jackson3.toData(payload);
assertTrue(data3.isPresent());
assertEquals(payload, jackson2.fromData(data3.get(), TestPayload.class, TestPayload.class));
}

@Test
public void testSetDefaultAsJackson3() {
JacksonJsonPayloadConverter.setDefaultAsJackson3(true, false);

Optional<Payload> data =
GlobalDataConverter.get().toPayload(new TestPayload(1L, Instant.now(), "delegated"));
assertTrue(data.isPresent());

// Alphabetical field order proves Jackson 3 native is being used
String json = data.get().getData().toStringUtf8();
assertTrue(
"Expected alphabetical field order (Jackson 3 native), got: " + json,
json.indexOf("\"name\"") < json.indexOf("\"timestamp\""));
}

@Test
public void testSetDefaultAsJackson3WithCompat() {
JacksonJsonPayloadConverter.setDefaultAsJackson3(true, true);

Optional<Payload> data =
GlobalDataConverter.get().toPayload(new TestPayload(1L, Instant.now(), "delegated-compat"));
assertTrue(data.isPresent());

// Declaration field order proves Jackson 3 with jackson2Compat is being used
String json = data.get().getData().toStringUtf8();
assertTrue(
"Expected declaration field order (jackson2Compat), got: " + json,
json.indexOf("\"timestamp\"") < json.indexOf("\"name\""));
}

@Test
public void testExplicitObjectMapperIgnoresJackson3Delegate() {
// Enable Jackson 3 native globally (which sorts fields alphabetically)
JacksonJsonPayloadConverter.setDefaultAsJackson3(true, false);

// Converter created with explicit ObjectMapper should NOT delegate to Jackson 3
ObjectMapper mapper = JacksonJsonPayloadConverter.newDefaultObjectMapper();
JacksonJsonPayloadConverter converter = new JacksonJsonPayloadConverter(mapper);

TestPayload payload = new TestPayload(1L, Instant.now(), "explicit");
Optional<Payload> data = converter.toData(payload);
assertTrue(data.isPresent());

// Declaration field order proves Jackson 2 is still being used, not the Jackson 3 delegate
String json = data.get().getData().toStringUtf8();
assertTrue(
"Expected declaration field order (Jackson 2), got: " + json,
json.indexOf("\"timestamp\"") < json.indexOf("\"name\""));
}

static class TestPayload {
private long id;
private Instant timestamp;
private String name;

public TestPayload() {}

TestPayload(long id, Instant timestamp, String name) {
this.id = id;
this.timestamp = timestamp;
this.name = name;
}

public long getId() {
return id;
}

public void setId(long id) {
this.id = id;
}

public Instant getTimestamp() {
return timestamp;
}

public void setTimestamp(Instant timestamp) {
this.timestamp = timestamp;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
TestPayload that = (TestPayload) o;
return id == that.id
&& Objects.equals(timestamp, that.timestamp)
&& Objects.equals(name, that.name);
}

@Override
public int hashCode() {
return Objects.hash(id, timestamp, name);
}

@Override
public String toString() {
return "TestPayload{"
+ "id="
+ id
+ ", timestamp="
+ timestamp
+ ", name='"
+ name
+ '\''
+ '}';
}
}
}
Loading
Loading