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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ to docs, or any other relevant information.
## [Unreleased]

### Added
- Worker heartbeats now report detected Graal native-image and Kotlin runtimes alongside the JVM
version. Detection is best effort and respects the worker environment information opt-out.
- Added experimental `ChildWorkflowOptions.Builder.setVersioningOverride` and
`VersioningOverride.OneTimeVersioningOverride` for explicit pinned, auto-upgrade, and one-time
child workflow routing. Invalid child overrides are reported as `InvalidVersioningOverrideFailure`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package io.temporal.worker;

import io.temporal.activity.ActivityInterface;
import io.temporal.workflow.WorkflowInterface;
import io.temporal.workflow.WorkflowMethod;

/** Java implementations without Kotlin metadata, even when Kotlin is on the classpath. */
public final class JavaHeartbeatImplementations {
private JavaHeartbeatImplementations() {}

@WorkflowInterface
public interface JavaWorkflow {
@WorkflowMethod
void execute();
}

public static class JavaWorkflowImpl implements JavaWorkflow {
@Override
public void execute() {}
}

@ActivityInterface
public interface JavaActivity {
void executeActivity();
}

public static class JavaActivityImpl implements JavaActivity {
@Override
public void executeActivity() {}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package io.temporal.worker

import io.temporal.activity.ActivityInterface
import io.temporal.api.worker.v1.EnvironmentInfo
import io.temporal.api.worker.v1.EnvironmentInfo.Runtime.RuntimeType
import io.temporal.client.WorkflowClientOptions
import io.temporal.testing.TestEnvironmentOptions
import io.temporal.testing.TestWorkflowEnvironment
import io.temporal.workflow.WorkflowInterface
import io.temporal.workflow.WorkflowMethod
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.time.Duration

class KotlinWorkerHeartbeatTest {
private lateinit var testEnvironment: TestWorkflowEnvironment

private val jvmRuntime = EnvironmentInfo.Runtime.newBuilder()
.setType(RuntimeType.RUNTIME_TYPE_JVM)
.setVersion("test-jvm-version")
.build()
private val environment = EnvironmentInfo.newBuilder()
.addRuntimes(jvmRuntime)
.build()

@Before
fun setUp() {
// Exercise callbacks directly without a background heartbeat accepting the environment.
testEnvironment = TestWorkflowEnvironment.newInstance(
TestEnvironmentOptions.newBuilder()
.setWorkflowClientOptions(
WorkflowClientOptions.newBuilder()
.setWorkerHeartbeatInterval(Duration.ofSeconds(-1))
.build()
)
.build()
)
}

@After
fun tearDown() {
testEnvironment.close()
}

@Test
fun kotlinWorkflowRegistrationReportsKotlin() {
val worker = testEnvironment.newWorker("kotlin-workflow")
worker.registerWorkflowImplementationTypes(KotlinWorkflowImpl::class.java)
testEnvironment.start()

assertKotlinEnvironment(worker.buildHeartbeatCallback("group", environment).get().environment)
}

@Test
fun kotlinWorkflowRegistrationWithOptionsReportsKotlin() {
val worker = testEnvironment.newWorker("kotlin-workflow-options")
worker.registerWorkflowImplementationTypes(
WorkflowImplementationOptions.newBuilder().build(),
KotlinWorkflowImpl::class.java
)
testEnvironment.start()

assertKotlinEnvironment(worker.buildHeartbeatCallback("group", environment).get().environment)
}

@Test
fun kotlinActivityRegistrationReportsKotlinForLocalOnlyWorker() {
val worker = testEnvironment.newWorker(
"kotlin-local-activity",
WorkerOptions.newBuilder().setLocalActivityWorkerOnly(true).build()
)
worker.registerActivitiesImplementations(KotlinActivityImpl())
testEnvironment.start()

assertKotlinEnvironment(worker.buildHeartbeatCallback("group", environment).get().environment)
}

@Test
fun kotlinDetectionIsPerWorkerNotPerClasspathOrFactory() {
val kotlinWorker = testEnvironment.newWorker("kotlin")
kotlinWorker.registerWorkflowImplementationTypes(KotlinWorkflowImpl::class.java)
val javaWorker = testEnvironment.newWorker("java")
javaWorker.registerWorkflowImplementationTypes(JavaHeartbeatImplementations.JavaWorkflowImpl::class.java)
javaWorker.registerActivitiesImplementations(JavaHeartbeatImplementations.JavaActivityImpl())
val unregisteredWorker = testEnvironment.newWorker("unregistered")
testEnvironment.start()

assertKotlinEnvironment(kotlinWorker.buildHeartbeatCallback("group", environment).get().environment)
assertEquals(environment, javaWorker.buildHeartbeatCallback("group", environment).get().environment)
assertEquals(environment, unregisteredWorker.buildHeartbeatCallback("group", environment).get().environment)
}

@Test
fun environmentIsRetriedUntilHeartbeatAccepted() {
val worker = testEnvironment.newWorker("retry")
worker.registerWorkflowImplementationTypes(KotlinWorkflowImpl::class.java)
testEnvironment.start()
val heartbeat = worker.buildHeartbeatCallback("group", environment)

val first = heartbeat.get()
assertTrue(first.hasEnvironment())
assertKotlinEnvironment(first.environment)
repeat(3) {
val retry = heartbeat.get()
assertTrue(retry.hasEnvironment())
assertEquals(first.environment, retry.environment)
}

worker.onHeartbeatAccepted()
repeat(3) {
assertFalse(heartbeat.get().hasEnvironment())
}
}

@Test
fun nullEnvironmentOptsOutEvenForKotlinImplementations() {
val worker = testEnvironment.newWorker("optout")
worker.registerWorkflowImplementationTypes(KotlinWorkflowImpl::class.java)
worker.registerActivitiesImplementations(KotlinActivityImpl())
testEnvironment.start()
val heartbeat = worker.buildHeartbeatCallback("group", null)

repeat(3) {
assertFalse(heartbeat.get().hasEnvironment())
}
worker.onHeartbeatAccepted()
assertFalse(heartbeat.get().hasEnvironment())
}

private fun assertKotlinEnvironment(actual: EnvironmentInfo) {
assertEquals(2, actual.runtimesCount)
assertEquals(jvmRuntime, actual.runtimesList.single { it.type == RuntimeType.RUNTIME_TYPE_JVM })
val kotlinRuntime = actual.runtimesList.single { it.type == RuntimeType.RUNTIME_TYPE_KOTLIN }
assertEquals(KotlinVersion.CURRENT.toString(), kotlinRuntime.version)
// Augmenting the heartbeat must not mutate the caller's environment.
assertEquals(listOf(jvmRuntime), environment.runtimesList)
}

@WorkflowInterface
interface KotlinWorkflow {
@WorkflowMethod
fun execute()
}

class KotlinWorkflowImpl : KotlinWorkflow {
override fun execute() {}
}

@ActivityInterface
interface KotlinActivity {
fun executeActivity()
}

class KotlinActivityImpl : KotlinActivity {
override fun executeActivity() {}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -190,9 +190,10 @@ public Builder setWorkerHeartbeatInterval(Duration workerHeartbeatInterval) {
}

/**
* Disables reporting the JVM version, detected hosting environments (Docker, Kubernetes, cloud
* platforms), and OS platform in worker heartbeats. This information is sent once per worker,
* with the first heartbeat accepted by the server.
* Disables reporting runtime versions (JVM, detected Graal native images, and Kotlin), detected
* hosting environments (Docker, Kubernetes, cloud platforms), and OS platform in worker
* heartbeats. This information is sent once per worker, with the first heartbeat accepted by
* the server.
*
* @param disableWorkerEnvironmentInfo true to omit environment information from heartbeats
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
import io.temporal.api.worker.v1.EnvironmentInfo.Runtime.RuntimeType;
import io.temporal.api.worker.v1.EnvironmentInfo.WindowsPlatform;
import java.io.IOException;
import java.lang.annotation.Annotation;
import java.lang.annotation.AnnotationFormatError;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
Expand All @@ -33,28 +35,110 @@ public final class WorkerEnvironmentInfo {
private WorkerEnvironmentInfo() {}

/**
* Never throws: this runs during client creation, and telemetry must not break it. System
* property, environment, and filesystem access can all fail under a security manager, in which
* case whatever was collected before the failure is returned.
* Best effort: this runs during client creation, and telemetry must not break it. Each probe is
* isolated so unavailable runtime information does not prevent platform or hosting detection.
*/
public static EnvironmentInfo detect() {
return detect(System::getProperty);
}

static EnvironmentInfo detect(Function<String, String> properties) {
EnvironmentInfo.Builder builder = EnvironmentInfo.newBuilder();
builder.addRuntimes(
Runtime.newBuilder()
.setType(RuntimeType.RUNTIME_TYPE_JVM)
.setVersion(propertyValue(properties, "java.version")));
try {
if ("runtime".equals(properties.apply("org.graalvm.nativeimage.imagecode"))) {
builder.addRuntimes(
Runtime.newBuilder()
.setType(RuntimeType.RUNTIME_TYPE_GRAAL_AOT)
.setVersion(propertyValue(properties, "org.graalvm.version")));
}
} catch (RuntimeException | LinkageError e) {
log.info("Failed to detect Graal native-image runtime", e);
}
try {
builder.addRuntimes(
Runtime.newBuilder()
.setType(RuntimeType.RUNTIME_TYPE_JVM)
.setVersion(nullToEmpty(System.getProperty("java.version"))));
builder.addAllHostingEnvironments(detectHostingEnvironments(System::getenv));
} catch (RuntimeException | LinkageError e) {
log.info("Failed to detect worker hosting environments", e);
}
try {
Platform platform = detectPlatform();
if (platform != null) {
builder.setPlatform(platform);
}
} catch (RuntimeException e) {
log.info("Failed to detect worker environment information, reporting partial results", e);
} catch (RuntimeException | LinkageError e) {
log.info("Failed to detect worker platform", e);
}
return builder.build();
}

private static String propertyValue(Function<String, String> properties, String name) {
try {
return nullToEmpty(properties.apply(name));
} catch (RuntimeException | LinkageError e) {
return "";
}
}

/**
* Detects Kotlin implementation classes without a Kotlin dependency or assuming a shared
* annotation class loader.
*/
public static boolean isKotlinImplementation(@Nullable Class<?> implementationClass) {
if (implementationClass == null) {
return false;
}
try {
for (Annotation annotation : implementationClass.getDeclaredAnnotations()) {
if ("kotlin.Metadata".equals(annotation.annotationType().getName())) {
return true;
}
}
} catch (RuntimeException | LinkageError | AnnotationFormatError e) {
log.info("Failed to detect Kotlin implementation metadata", e);
}
return false;
}

/**
* Returns immutable environment information augmented with Kotlin when the implementation has
* Kotlin metadata. A null environment (telemetry opt-out) is preserved, as are existing runtimes.
*/
@Nullable
public static EnvironmentInfo withKotlinRuntime(
@Nullable EnvironmentInfo info, @Nullable Class<?> implementationClass) {
if (info == null) {
return null;
}
for (Runtime runtime : info.getRuntimesList()) {
if (runtime.getType() == RuntimeType.RUNTIME_TYPE_KOTLIN) {
return info;
}
}
if (!isKotlinImplementation(implementationClass)) {
return info;
}
return info.toBuilder()
.addRuntimes(
Runtime.newBuilder()
.setType(RuntimeType.RUNTIME_TYPE_KOTLIN)
.setVersion(kotlinVersion(implementationClass)))
.build();
}

private static String kotlinVersion(Class<?> implementationClass) {
try {
Class<?> version =
Class.forName("kotlin.KotlinVersion", true, implementationClass.getClassLoader());
Object current = version.getField("CURRENT").get(null);
return current == null ? "" : current.toString();
} catch (ReflectiveOperationException | RuntimeException | LinkageError e) {
return "";
}
}

/**
* Several environments may be detected at once, e.g. Docker inside Kubernetes or Azure Functions
* inside Azure App Service.
Expand Down
Loading
Loading