Skip to content

Commit b4ca68f

Browse files
Report Kotlin and Graal runtimes in worker heartbeats
Detect directly registered Kotlin workflow and activity implementations per worker and include the Kotlin version alongside the JVM runtime. Report Graal native-image execution when detected. Keep detection best effort and preserve telemetry opt-out and heartbeat acceptance behavior.
1 parent 2fe5ec8 commit b4ca68f

7 files changed

Lines changed: 511 additions & 14 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ to docs, or any other relevant information.
2020
## [Unreleased]
2121

2222
### Added
23+
- Worker heartbeats now report detected Graal native-image and Kotlin runtimes alongside the JVM
24+
version. Detection is best effort and respects the worker environment information opt-out.
2325
- Added experimental `ChildWorkflowOptions.Builder.setVersioningOverride` and
2426
`VersioningOverride.OneTimeVersioningOverride` for explicit pinned, auto-upgrade, and one-time
2527
child workflow routing. Invalid child overrides are reported as `InvalidVersioningOverrideFailure`
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package io.temporal.worker;
2+
3+
import io.temporal.activity.ActivityInterface;
4+
import io.temporal.workflow.WorkflowInterface;
5+
import io.temporal.workflow.WorkflowMethod;
6+
7+
/** Java implementations without Kotlin metadata, even when Kotlin is on the classpath. */
8+
public final class JavaHeartbeatImplementations {
9+
private JavaHeartbeatImplementations() {}
10+
11+
@WorkflowInterface
12+
public interface JavaWorkflow {
13+
@WorkflowMethod
14+
void execute();
15+
}
16+
17+
public static class JavaWorkflowImpl implements JavaWorkflow {
18+
@Override
19+
public void execute() {}
20+
}
21+
22+
@ActivityInterface
23+
public interface JavaActivity {
24+
void executeActivity();
25+
}
26+
27+
public static class JavaActivityImpl implements JavaActivity {
28+
@Override
29+
public void executeActivity() {}
30+
}
31+
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package io.temporal.worker
2+
3+
import io.temporal.activity.ActivityInterface
4+
import io.temporal.api.worker.v1.EnvironmentInfo
5+
import io.temporal.api.worker.v1.EnvironmentInfo.Runtime.RuntimeType
6+
import io.temporal.client.WorkflowClientOptions
7+
import io.temporal.testing.TestEnvironmentOptions
8+
import io.temporal.testing.TestWorkflowEnvironment
9+
import io.temporal.workflow.WorkflowInterface
10+
import io.temporal.workflow.WorkflowMethod
11+
import org.junit.After
12+
import org.junit.Assert.assertEquals
13+
import org.junit.Assert.assertFalse
14+
import org.junit.Assert.assertTrue
15+
import org.junit.Before
16+
import org.junit.Test
17+
import java.time.Duration
18+
19+
class KotlinWorkerHeartbeatTest {
20+
private lateinit var testEnvironment: TestWorkflowEnvironment
21+
22+
private val jvmRuntime = EnvironmentInfo.Runtime.newBuilder()
23+
.setType(RuntimeType.RUNTIME_TYPE_JVM)
24+
.setVersion("test-jvm-version")
25+
.build()
26+
private val environment = EnvironmentInfo.newBuilder()
27+
.addRuntimes(jvmRuntime)
28+
.build()
29+
30+
@Before
31+
fun setUp() {
32+
// Exercise callbacks directly without a background heartbeat accepting the environment.
33+
testEnvironment = TestWorkflowEnvironment.newInstance(
34+
TestEnvironmentOptions.newBuilder()
35+
.setWorkflowClientOptions(
36+
WorkflowClientOptions.newBuilder()
37+
.setWorkerHeartbeatInterval(Duration.ofSeconds(-1))
38+
.build()
39+
)
40+
.build()
41+
)
42+
}
43+
44+
@After
45+
fun tearDown() {
46+
testEnvironment.close()
47+
}
48+
49+
@Test
50+
fun kotlinWorkflowRegistrationReportsKotlin() {
51+
val worker = testEnvironment.newWorker("kotlin-workflow")
52+
worker.registerWorkflowImplementationTypes(KotlinWorkflowImpl::class.java)
53+
testEnvironment.start()
54+
55+
assertKotlinEnvironment(worker.buildHeartbeatCallback("group", environment).get().environment)
56+
}
57+
58+
@Test
59+
fun kotlinWorkflowRegistrationWithOptionsReportsKotlin() {
60+
val worker = testEnvironment.newWorker("kotlin-workflow-options")
61+
worker.registerWorkflowImplementationTypes(
62+
WorkflowImplementationOptions.newBuilder().build(),
63+
KotlinWorkflowImpl::class.java
64+
)
65+
testEnvironment.start()
66+
67+
assertKotlinEnvironment(worker.buildHeartbeatCallback("group", environment).get().environment)
68+
}
69+
70+
@Test
71+
fun kotlinActivityRegistrationReportsKotlinForLocalOnlyWorker() {
72+
val worker = testEnvironment.newWorker(
73+
"kotlin-local-activity",
74+
WorkerOptions.newBuilder().setLocalActivityWorkerOnly(true).build()
75+
)
76+
worker.registerActivitiesImplementations(KotlinActivityImpl())
77+
testEnvironment.start()
78+
79+
assertKotlinEnvironment(worker.buildHeartbeatCallback("group", environment).get().environment)
80+
}
81+
82+
@Test
83+
fun kotlinDetectionIsPerWorkerNotPerClasspathOrFactory() {
84+
val kotlinWorker = testEnvironment.newWorker("kotlin")
85+
kotlinWorker.registerWorkflowImplementationTypes(KotlinWorkflowImpl::class.java)
86+
val javaWorker = testEnvironment.newWorker("java")
87+
javaWorker.registerWorkflowImplementationTypes(JavaHeartbeatImplementations.JavaWorkflowImpl::class.java)
88+
javaWorker.registerActivitiesImplementations(JavaHeartbeatImplementations.JavaActivityImpl())
89+
val unregisteredWorker = testEnvironment.newWorker("unregistered")
90+
testEnvironment.start()
91+
92+
assertKotlinEnvironment(kotlinWorker.buildHeartbeatCallback("group", environment).get().environment)
93+
assertEquals(environment, javaWorker.buildHeartbeatCallback("group", environment).get().environment)
94+
assertEquals(environment, unregisteredWorker.buildHeartbeatCallback("group", environment).get().environment)
95+
}
96+
97+
@Test
98+
fun environmentIsRetriedUntilHeartbeatAccepted() {
99+
val worker = testEnvironment.newWorker("retry")
100+
worker.registerWorkflowImplementationTypes(KotlinWorkflowImpl::class.java)
101+
testEnvironment.start()
102+
val heartbeat = worker.buildHeartbeatCallback("group", environment)
103+
104+
val first = heartbeat.get()
105+
assertTrue(first.hasEnvironment())
106+
assertKotlinEnvironment(first.environment)
107+
repeat(3) {
108+
val retry = heartbeat.get()
109+
assertTrue(retry.hasEnvironment())
110+
assertEquals(first.environment, retry.environment)
111+
}
112+
113+
worker.onHeartbeatAccepted()
114+
repeat(3) {
115+
assertFalse(heartbeat.get().hasEnvironment())
116+
}
117+
}
118+
119+
@Test
120+
fun nullEnvironmentOptsOutEvenForKotlinImplementations() {
121+
val worker = testEnvironment.newWorker("optout")
122+
worker.registerWorkflowImplementationTypes(KotlinWorkflowImpl::class.java)
123+
worker.registerActivitiesImplementations(KotlinActivityImpl())
124+
testEnvironment.start()
125+
val heartbeat = worker.buildHeartbeatCallback("group", null)
126+
127+
repeat(3) {
128+
assertFalse(heartbeat.get().hasEnvironment())
129+
}
130+
worker.onHeartbeatAccepted()
131+
assertFalse(heartbeat.get().hasEnvironment())
132+
}
133+
134+
private fun assertKotlinEnvironment(actual: EnvironmentInfo) {
135+
assertEquals(2, actual.runtimesCount)
136+
assertEquals(jvmRuntime, actual.runtimesList.single { it.type == RuntimeType.RUNTIME_TYPE_JVM })
137+
val kotlinRuntime = actual.runtimesList.single { it.type == RuntimeType.RUNTIME_TYPE_KOTLIN }
138+
assertEquals(KotlinVersion.CURRENT.toString(), kotlinRuntime.version)
139+
// Augmenting the heartbeat must not mutate the caller's environment.
140+
assertEquals(listOf(jvmRuntime), environment.runtimesList)
141+
}
142+
143+
@WorkflowInterface
144+
interface KotlinWorkflow {
145+
@WorkflowMethod
146+
fun execute()
147+
}
148+
149+
class KotlinWorkflowImpl : KotlinWorkflow {
150+
override fun execute() {}
151+
}
152+
153+
@ActivityInterface
154+
interface KotlinActivity {
155+
fun executeActivity()
156+
}
157+
158+
class KotlinActivityImpl : KotlinActivity {
159+
override fun executeActivity() {}
160+
}
161+
}

‎temporal-sdk/src/main/java/io/temporal/client/WorkflowClientOptions.java‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -190,9 +190,10 @@ public Builder setWorkerHeartbeatInterval(Duration workerHeartbeatInterval) {
190190
}
191191

192192
/**
193-
* Disables reporting the JVM version, detected hosting environments (Docker, Kubernetes, cloud
194-
* platforms), and OS platform in worker heartbeats. This information is sent once per worker,
195-
* with the first heartbeat accepted by the server.
193+
* Disables reporting runtime versions (JVM, detected Graal native images, and Kotlin), detected
194+
* hosting environments (Docker, Kubernetes, cloud platforms), and OS platform in worker
195+
* heartbeats. This information is sent once per worker, with the first heartbeat accepted by
196+
* the server.
196197
*
197198
* @param disableWorkerEnvironmentInfo true to omit environment information from heartbeats
198199
*/

‎temporal-sdk/src/main/java/io/temporal/internal/worker/WorkerEnvironmentInfo.java‎

Lines changed: 93 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
import io.temporal.api.worker.v1.EnvironmentInfo.Runtime.RuntimeType;
1212
import io.temporal.api.worker.v1.EnvironmentInfo.WindowsPlatform;
1313
import java.io.IOException;
14+
import java.lang.annotation.Annotation;
15+
import java.lang.annotation.AnnotationFormatError;
1416
import java.nio.charset.StandardCharsets;
1517
import java.nio.file.Files;
1618
import java.nio.file.Path;
@@ -33,28 +35,110 @@ public final class WorkerEnvironmentInfo {
3335
private WorkerEnvironmentInfo() {}
3436

3537
/**
36-
* Never throws: this runs during client creation, and telemetry must not break it. System
37-
* property, environment, and filesystem access can all fail under a security manager, in which
38-
* case whatever was collected before the failure is returned.
38+
* Best effort: this runs during client creation, and telemetry must not break it. Each probe is
39+
* isolated so unavailable runtime information does not prevent platform or hosting detection.
3940
*/
4041
public static EnvironmentInfo detect() {
42+
return detect(System::getProperty);
43+
}
44+
45+
static EnvironmentInfo detect(Function<String, String> properties) {
4146
EnvironmentInfo.Builder builder = EnvironmentInfo.newBuilder();
47+
builder.addRuntimes(
48+
Runtime.newBuilder()
49+
.setType(RuntimeType.RUNTIME_TYPE_JVM)
50+
.setVersion(propertyValue(properties, "java.version")));
51+
try {
52+
if ("runtime".equals(properties.apply("org.graalvm.nativeimage.imagecode"))) {
53+
builder.addRuntimes(
54+
Runtime.newBuilder()
55+
.setType(RuntimeType.RUNTIME_TYPE_GRAAL_AOT)
56+
.setVersion(propertyValue(properties, "org.graalvm.version")));
57+
}
58+
} catch (RuntimeException | LinkageError e) {
59+
log.info("Failed to detect Graal native-image runtime", e);
60+
}
4261
try {
43-
builder.addRuntimes(
44-
Runtime.newBuilder()
45-
.setType(RuntimeType.RUNTIME_TYPE_JVM)
46-
.setVersion(nullToEmpty(System.getProperty("java.version"))));
4762
builder.addAllHostingEnvironments(detectHostingEnvironments(System::getenv));
63+
} catch (RuntimeException | LinkageError e) {
64+
log.info("Failed to detect worker hosting environments", e);
65+
}
66+
try {
4867
Platform platform = detectPlatform();
4968
if (platform != null) {
5069
builder.setPlatform(platform);
5170
}
52-
} catch (RuntimeException e) {
53-
log.info("Failed to detect worker environment information, reporting partial results", e);
71+
} catch (RuntimeException | LinkageError e) {
72+
log.info("Failed to detect worker platform", e);
5473
}
5574
return builder.build();
5675
}
5776

77+
private static String propertyValue(Function<String, String> properties, String name) {
78+
try {
79+
return nullToEmpty(properties.apply(name));
80+
} catch (RuntimeException | LinkageError e) {
81+
return "";
82+
}
83+
}
84+
85+
/**
86+
* Detects Kotlin implementation classes without a Kotlin dependency or assuming a shared
87+
* annotation class loader.
88+
*/
89+
public static boolean isKotlinImplementation(@Nullable Class<?> implementationClass) {
90+
if (implementationClass == null) {
91+
return false;
92+
}
93+
try {
94+
for (Annotation annotation : implementationClass.getDeclaredAnnotations()) {
95+
if ("kotlin.Metadata".equals(annotation.annotationType().getName())) {
96+
return true;
97+
}
98+
}
99+
} catch (RuntimeException | LinkageError | AnnotationFormatError e) {
100+
log.info("Failed to detect Kotlin implementation metadata", e);
101+
}
102+
return false;
103+
}
104+
105+
/**
106+
* Returns immutable environment information augmented with Kotlin when the implementation has
107+
* Kotlin metadata. A null environment (telemetry opt-out) is preserved, as are existing runtimes.
108+
*/
109+
@Nullable
110+
public static EnvironmentInfo withKotlinRuntime(
111+
@Nullable EnvironmentInfo info, @Nullable Class<?> implementationClass) {
112+
if (info == null) {
113+
return null;
114+
}
115+
for (Runtime runtime : info.getRuntimesList()) {
116+
if (runtime.getType() == RuntimeType.RUNTIME_TYPE_KOTLIN) {
117+
return info;
118+
}
119+
}
120+
if (!isKotlinImplementation(implementationClass)) {
121+
return info;
122+
}
123+
return info.toBuilder()
124+
.addRuntimes(
125+
Runtime.newBuilder()
126+
.setType(RuntimeType.RUNTIME_TYPE_KOTLIN)
127+
.setVersion(kotlinVersion(implementationClass)))
128+
.build();
129+
}
130+
131+
private static String kotlinVersion(Class<?> implementationClass) {
132+
try {
133+
Class<?> version =
134+
Class.forName("kotlin.KotlinVersion", true, implementationClass.getClassLoader());
135+
Object current = version.getField("CURRENT").get(null);
136+
return current == null ? "" : current.toString();
137+
} catch (ReflectiveOperationException | RuntimeException | LinkageError e) {
138+
return "";
139+
}
140+
}
141+
58142
/**
59143
* Several environments may be detected at once, e.g. Docker inside Kubernetes or Azure Functions
60144
* inside Azure App Service.

0 commit comments

Comments
 (0)