From c8b1255b14792a7992f8b1cced5eb635e2f57c71 Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Thu, 30 Jul 2026 15:42:19 -0700 Subject: [PATCH 1/9] Download dev server via gradle --- .github/workflows/ci.yml | 55 +------- CONTRIBUTING.md | 36 +++++ build.gradle | 3 +- gradle/temporalCli.gradle | 287 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 329 insertions(+), 52 deletions(-) create mode 100644 gradle/temporalCli.gradle diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 339a274928..aa69f55f5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,51 +82,8 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 - - name: Start CLI server - env: - TEMPORAL_CLI_VERSION: 1.7.4-standalone-nexus-operations - run: | - wget -O temporal_cli.tar.gz https://github.com/temporalio/cli/releases/download/v${TEMPORAL_CLI_VERSION}/temporal_cli_${TEMPORAL_CLI_VERSION}_linux_amd64.tar.gz - tar -xzf temporal_cli.tar.gz - chmod +x temporal - ./temporal server start-dev \ - --headless \ - --port 7233 \ - --http-port 7243 \ - --namespace UnitTest \ - --db-filename temporal.sqlite \ - --sqlite-pragma journal_mode=WAL \ - --sqlite-pragma synchronous=OFF \ - --search-attribute CustomKeywordField=Keyword \ - --search-attribute CustomStringField=Text \ - --search-attribute CustomTextField=Text \ - --search-attribute CustomIntField=Int \ - --search-attribute CustomDatetimeField=Datetime \ - --search-attribute CustomDoubleField=Double \ - --search-attribute CustomBoolField=Bool \ - --dynamic-config-value system.enableActivityEagerExecution=true \ - --dynamic-config-value history.MaxBufferedQueryCount=10000 \ - --dynamic-config-value frontend.workerVersioningDataAPIs=true \ - --dynamic-config-value history.enableRequestIdRefLinks=true \ - --dynamic-config-value frontend.WorkerHeartbeatsEnabled=true \ - --dynamic-config-value frontend.ListWorkersEnabled=true \ - --dynamic-config-value frontend.enableCancelWorkerPollsOnShutdown=true \ - --dynamic-config-value 'component.callbacks.allowedAddresses=[{"Pattern":"localhost:7243","AllowInsecure":true}]' \ - --dynamic-config-value 'callback.allowedAddresses=[{"Pattern":"localhost:7243","AllowInsecure":true}]' \ - --dynamic-config-value frontend.activityAPIsEnabled=true \ - --dynamic-config-value activity.enableStandalone=true \ - --dynamic-config-value activity.enableCallbacks=true \ - --dynamic-config-value activity.startDelayEnabled=true \ - --dynamic-config-value nexusoperation.enableStandalone=true \ - --dynamic-config-value history.enableChasm=true \ - --dynamic-config-value history.enableCHASMSignalBacklinks=true \ - --dynamic-config-value history.enableTransitionHistory=true \ - --dynamic-config-value history.enableUpdateCallbacks=true \ - --dynamic-config-value history.enableCHASMCallbacks=true \ - --dynamic-config-value frontend.enableCancelWorkerPollsOnShutdown=true \ - --dynamic-config-value frontend.workerCommandsEnabled=true \ - --dynamic-config-value system.enableCancelActivityWorkerCommand=true & - sleep 10s + - name: Prepare CLI-backed tests + run: ./gradlew --no-daemon prepareTemporalCliTests -x spotlessCheck -x spotlessApply -x spotlessJava # Can't actually run tests against Java 8 because Mockito 5 requires Java 11+. # We therefore have to rely on the fact that the code has been compiled with @@ -135,9 +92,7 @@ jobs: - name: Run unit tests (Java 11) env: USER: unittest - TEMPORAL_SERVICE_ADDRESS: localhost:7233 - USE_EXTERNAL_SERVICE: true - run: ./gradlew --no-daemon test -x spotlessCheck -x spotlessApply -x spotlessJava -PtestJavaVersion=11 + run: ./gradlew --no-daemon --offline test -x spotlessCheck -x spotlessApply -x spotlessJava -PtestJavaVersion=11 -PuseTemporalCli - name: Run Jackson 3 converter tests (Java 17) env: @@ -148,9 +103,7 @@ jobs: - name: Run virtual thread tests (Java 21) env: USER: unittest - TEMPORAL_SERVICE_ADDRESS: localhost:7233 - USE_EXTERNAL_SERVICE: true - run: ./gradlew --no-daemon :temporal-sdk:virtualThreadTests -x spotlessCheck -x spotlessApply -x spotlessJava -PtestJavaVersion=21 + run: ./gradlew --no-daemon --offline :temporal-sdk:virtualThreadTests -x spotlessCheck -x spotlessApply -x spotlessJava -PtestJavaVersion=21 -PuseTemporalCli - name: Publish Test Report uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fcc672866f..ee135ed21a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,6 +68,42 @@ Good pull requests are focused and easy to review: Run the relevant local checks when practical. CI must pass before a pull request can be merged. +## SDK Java Development + +Java 21 or later is required to run Gradle, compile the project, and run all tests +locally. + +By default, integration tests run against the built-in time-skipping test server. +Some tests require features that the built-in server does not support and are +skipped. Gradle can download the pinned Temporal CLI, start a correctly configured +dev server, wait for it to become ready, configure the tests to use it, and stop it +when the test invocation finishes. + +Prepare the CLI-backed tests with: + +```bash +./gradlew prepareTemporalCliTests +``` + +This caches the platform-specific CLI under the Gradle user home and resolves the +build and test dependencies needed by the Java 11 unit tests and Java 21 +virtual-thread tests. Run the CLI-backed CI coverage with: + +```bash +./gradlew test -PtestJavaVersion=11 -PuseTemporalCli +./gradlew :temporal-sdk:virtualThreadTests -PtestJavaVersion=21 -PuseTemporalCli +``` + +Normal Gradle test filtering works, so a single CLI-backed test can be reproduced: + +```bash +./gradlew :temporal-sdk:test -PtestJavaVersion=11 -PuseTemporalCli \ + --tests "io.temporal.activity.ActivityPauseTest.activityPause" +``` + +Java 11 and Java 21 installations must be available to Gradle for the corresponding +commands. Server output is written to `build/temporal-cli/server/server.log`. + ## Things to Avoid Avoid changes that make review harder without improving the contribution: diff --git a/build.gradle b/build.gradle index 6dcbcc7cb9..e9bbe424c0 100644 --- a/build.gradle +++ b/build.gradle @@ -77,6 +77,7 @@ apply from: "$rootDir/gradle/errorprone.gradle" apply from: "$rootDir/gradle/publishing.gradle" apply from: "$rootDir/gradle/dependencyManagement.gradle" apply from: "$rootDir/gradle/gatherDependencies.gradle" +apply from: "$rootDir/gradle/temporalCli.gradle" if (project.hasProperty("jacoco")) { apply from: "$rootDir/gradle/jacoco.gradle" -} \ No newline at end of file +} diff --git a/gradle/temporalCli.gradle b/gradle/temporalCli.gradle new file mode 100644 index 0000000000..377cbc8762 --- /dev/null +++ b/gradle/temporalCli.gradle @@ -0,0 +1,287 @@ +import java.util.concurrent.TimeUnit +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.logging.Logging +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.services.BuildService +import org.gradle.api.services.BuildServiceParameters +import org.gradle.api.tasks.JavaExec +import org.gradle.api.tasks.testing.Test + +abstract class TemporalCliServerService + implements BuildService, AutoCloseable { + interface Parameters extends BuildServiceParameters { + RegularFileProperty getExecutable() + + DirectoryProperty getWorkingDirectory() + + ListProperty getArguments() + + Property getReadinessUrl() + } + + private static final def logger = Logging.getLogger(TemporalCliServerService) + private Process serverProcess + + synchronized void start() { + if (serverProcess != null && serverProcess.isAlive()) { + return + } + + File executable = parameters.executable.get().asFile + File workingDirectory = parameters.workingDirectory.get().asFile + workingDirectory.mkdirs() + cleanDatabase(workingDirectory) + File logFile = new File(workingDirectory, 'server.log') + logFile.delete() + + logger.lifecycle("Starting the Temporal CLI dev server. Output: ${logFile}") + serverProcess = new ProcessBuilder( + [executable.absolutePath] + parameters.arguments.get()) + .directory(workingDirectory) + .redirectErrorStream(true) + .redirectOutput(ProcessBuilder.Redirect.to(logFile)) + .start() + + try { + waitUntilReady(logFile) + } catch (Throwable failure) { + stopServer(workingDirectory) + throw failure + } + } + + private void waitUntilReady(File logFile) { + Process readinessCheck = new ProcessBuilder( + 'curl', + '--fail', '--silent', '--show-error', + '--retry', '60', + '--retry-all-errors', + '--retry-connrefused', + '--retry-delay', '1', + '--retry-max-time', '60', + '--connect-timeout', '1', + '--max-time', '2', + parameters.readinessUrl.get()) + .redirectErrorStream(true) + .start() + String readinessOutput = readinessCheck.inputStream.getText('UTF-8').trim() + if (readinessCheck.waitFor() != 0) { + throw readinessFailure( + "Temporal CLI readiness check failed: ${readinessOutput}", logFile) + } + if (!serverProcess.isAlive()) { + throw readinessFailure( + "The Temporal CLI dev server exited with code ${serverProcess.exitValue()}.", + logFile) + } + logger.lifecycle("Temporal CLI dev server is ready at ${parameters.readinessUrl.get()}.") + } + + private static GradleException readinessFailure(String message, File logFile) { + String serverOutput = logFile.isFile() + ? logFile.readLines('UTF-8').takeRight(200).join(System.lineSeparator()) + : '' + return new GradleException( + "${message}\nTemporal CLI output (${logFile}):\n${serverOutput}") + } + + @Override + synchronized void close() { + stopServer(parameters.workingDirectory.get().asFile) + } + + private void stopServer(File workingDirectory) { + if (serverProcess != null && serverProcess.isAlive()) { + logger.lifecycle('Stopping the Temporal CLI dev server.') + serverProcess.destroy() + if (!serverProcess.waitFor(10, TimeUnit.SECONDS)) { + serverProcess.destroyForcibly() + serverProcess.waitFor(10, TimeUnit.SECONDS) + } + } + serverProcess = null + cleanDatabase(workingDirectory) + } + + private static void cleanDatabase(File workingDirectory) { + ['temporal.sqlite', 'temporal.sqlite-shm', 'temporal.sqlite-wal'].each { name -> + File databaseFile = new File(workingDirectory, name) + if (databaseFile.exists() && !databaseFile.delete()) { + logger.warn("Unable to delete Temporal CLI database file ${databaseFile}.") + } + } + } +} + +ext.temporalCliVersion = '1.7.2-standalone-nexus-operations' + +def osName = System.getProperty('os.name').toLowerCase(Locale.ROOT) +def temporalCliOs = osName.contains('mac') || osName.contains('darwin') + ? 'darwin' + : osName.contains('windows') ? 'windows' : 'linux' +def architecture = System.getProperty('os.arch').toLowerCase(Locale.ROOT) +def temporalCliArch = architecture in ['arm64', 'aarch64'] ? 'arm64' : 'amd64' +def temporalCliExecutableName = temporalCliOs == 'windows' ? 'temporal.exe' : 'temporal' +def temporalCliArchiveExtension = temporalCliOs == 'windows' ? 'zip' : 'tar.gz' +def temporalCliClassifier = "${temporalCliOs}_${temporalCliArch}" +def temporalCliCache = file( + "${gradle.gradleUserHomeDir}/caches/temporal-cli/${temporalCliVersion}/${temporalCliClassifier}") +def temporalCliExecutable = file("${temporalCliCache}/${temporalCliExecutableName}") +def temporalCliArchive = file( + "${temporalCliCache}/temporal_cli_${temporalCliVersion}_${temporalCliClassifier}.${temporalCliArchiveExtension}") +def temporalCliUrl = + "https://github.com/temporalio/cli/releases/download/v${temporalCliVersion}/${temporalCliArchive.name}" + +def downloadTemporalCliArchive = tasks.register('downloadTemporalCliArchive', Exec) { + onlyIf { + !temporalCliExecutable.isFile() && !temporalCliArchive.isFile() + } + doFirst { + if (gradle.startParameter.offline) { + throw new GradleException( + "Temporal CLI ${temporalCliVersion} for ${temporalCliClassifier} is not cached " + + "at ${temporalCliExecutable}. Run './gradlew prepareTemporalCliTests' " + + "before using Gradle offline.") + } + temporalCliCache.mkdirs() + } + commandLine( + 'curl', '--fail', '--location', '--retry', '3', + '--output', temporalCliArchive.absolutePath, temporalCliUrl) + outputs.file(temporalCliArchive) +} + +def extractTemporalCli = tasks.register('extractTemporalCli', Exec) { + dependsOn(downloadTemporalCliArchive) + onlyIf { + !temporalCliExecutable.isFile() + } + doFirst { + temporalCliCache.mkdirs() + } + commandLine( + 'tar', '-xf', temporalCliArchive.absolutePath, + '-C', temporalCliCache.absolutePath, temporalCliExecutableName) + outputs.file(temporalCliExecutable) +} + +def downloadTemporalCli = tasks.register('downloadTemporalCli') { + group = 'verification' + description = "Downloads and caches Temporal CLI ${temporalCliVersion} for this platform." + dependsOn(extractTemporalCli) + doLast { + if (!temporalCliExecutable.isFile()) { + throw new GradleException( + "Temporal CLI archive did not contain ${temporalCliExecutableName}.") + } + if (temporalCliOs != 'windows' && !temporalCliExecutable.setExecutable(true)) { + throw new GradleException("Unable to make ${temporalCliExecutable} executable.") + } + } +} + +def temporalServiceAddress = 'localhost:7233' +def temporalCliServerArguments = [ + 'server', 'start-dev', + '--headless', + '--port', '7233', + '--http-port', '7243', + '--namespace', 'UnitTest', + '--db-filename', 'temporal.sqlite', + '--sqlite-pragma', 'journal_mode=WAL', + '--sqlite-pragma', 'synchronous=OFF', + '--search-attribute', 'CustomKeywordField=Keyword', + '--search-attribute', 'CustomStringField=Text', + '--search-attribute', 'CustomTextField=Text', + '--search-attribute', 'CustomIntField=Int', + '--search-attribute', 'CustomDatetimeField=Datetime', + '--search-attribute', 'CustomDoubleField=Double', + '--search-attribute', 'CustomBoolField=Bool', + '--dynamic-config-value', 'system.enableActivityEagerExecution=true', + '--dynamic-config-value', 'history.MaxBufferedQueryCount=10000', + '--dynamic-config-value', 'frontend.workerVersioningDataAPIs=true', + '--dynamic-config-value', 'history.enableRequestIdRefLinks=true', + '--dynamic-config-value', 'frontend.WorkerHeartbeatsEnabled=true', + '--dynamic-config-value', 'frontend.ListWorkersEnabled=true', + '--dynamic-config-value', 'frontend.enableCancelWorkerPollsOnShutdown=true', + '--dynamic-config-value', + 'component.callbacks.allowedAddresses=[{"Pattern":"localhost:7243","AllowInsecure":true}]', + '--dynamic-config-value', + 'callback.allowedAddresses=[{"Pattern":"localhost:7243","AllowInsecure":true}]', + '--dynamic-config-value', 'frontend.activityAPIsEnabled=true', + '--dynamic-config-value', 'activity.enableStandalone=true', + '--dynamic-config-value', 'activity.startDelayEnabled=true', + '--dynamic-config-value', 'nexusoperation.enableStandalone=true', + '--dynamic-config-value', 'history.enableChasm=true', + '--dynamic-config-value', 'history.enableCHASMSignalBacklinks=true', + '--dynamic-config-value', 'history.enableTransitionHistory=true', + '--dynamic-config-value', 'frontend.enableCancelWorkerPollsOnShutdown=true', + '--dynamic-config-value', 'frontend.workerCommandsEnabled=true', + '--dynamic-config-value', 'system.enableCancelActivityWorkerCommand=true' +] + +def temporalCliServer = gradle.sharedServices.registerIfAbsent( + 'temporalCliServer', TemporalCliServerService) { + parameters.executable.fileValue(temporalCliExecutable) + parameters.workingDirectory.set(layout.buildDirectory.dir('temporal-cli/server')) + parameters.arguments.set(temporalCliServerArguments) + parameters.readinessUrl.set('http://127.0.0.1:7243/api/v1/namespaces/UnitTest') +} + +def prepareTemporalCliTests = tasks.register('prepareTemporalCliTests') { + group = 'verification' + description = + 'Caches Temporal CLI and all build inputs required by the CLI-backed test suites.' + dependsOn(downloadTemporalCli) +} + +gradle.projectsEvaluated { + List standardTestTasks = subprojects.collect { subproject -> + subproject.tasks.findByName('test') + }.findAll { task -> task instanceof Test } as List + + prepareTemporalCliTests.configure { + dependsOn standardTestTasks.collect { testTask -> + testTask.project.tasks.named('testClasses') + } + dependsOn project(':temporal-sdk').tasks.named('compileJava17Java') + dependsOn project(':temporal-sdk').tasks.named('compileJava21Java') + dependsOn project(':temporal-sdk').tasks.named('virtualThreadTestsClasses') + doLast { + standardTestTasks.each { testTask -> testTask.classpath.files } + project(':temporal-sdk').tasks.named('virtualThreadTests', Test).get().classpath.files + } + } + + if (!project.hasProperty('useTemporalCli') + || project.property('useTemporalCli').toString() == 'false') { + return + } + + allprojects.each { candidateProject -> + candidateProject.tasks.withType(Test).configureEach { + dependsOn(downloadTemporalCli) + usesService(temporalCliServer) + environment('TEMPORAL_SERVICE_ADDRESS', temporalServiceAddress) + environment('USE_EXTERNAL_SERVICE', 'true') + doFirst { + temporalCliServer.get().start() + } + } + + candidateProject.tasks.withType(JavaExec).matching { + it.name == 'registerNamespace' + }.configureEach { + dependsOn(downloadTemporalCli) + usesService(temporalCliServer) + environment('TEMPORAL_SERVICE_ADDRESS', temporalServiceAddress) + environment('USE_EXTERNAL_SERVICE', 'true') + doFirst { + temporalCliServer.get().start() + } + } + } +} From 1240ee6ec6aaee612ad1dae81fce211fca38ac47 Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Thu, 30 Jul 2026 17:36:45 -0700 Subject: [PATCH 2/9] Add dev server downloader/runner --- .github/workflows/ci.yml | 8 +- CONTRIBUTING.md | 20 +- gradle/temporalCli.gradle | 283 ++------------ .../io/temporal/worker/StickyWorkerTest.java | 6 +- .../workerFactory/WorkerFactoryTests.java | 6 +- .../autoconfigure/WorkerVersioningTest.java | 15 +- temporal-testing/build.gradle | 2 + .../temporal/testing/TemporalDevServer.java | 74 ++++ .../testing/TemporalDevServerOptions.java | 369 ++++++++++++++++++ .../testing/TestWorkflowEnvironment.java | 72 ++++ .../TestWorkflowEnvironmentInternal.java | 63 ++- .../testing/TestWorkflowExtension.java | 130 ++++-- .../io/temporal/testing/TestWorkflowRule.java | 60 ++- .../internal/DevServerTestPreparation.java | 13 + .../ExternalServiceTestConfigurator.java | 20 +- .../devserver/SdkJavaTestServerProfile.java | 171 ++++++++ .../TemporalDevServerDownloader.java | 360 +++++++++++++++++ .../devserver/TemporalDevServerLauncher.java | 295 ++++++++++++++ .../TemporalDevServerIntegrationTest.java | 208 ++++++++++ .../testing/TemporalDevServerOptionsTest.java | 61 +++ .../TemporalDevServerDownloaderTest.java | 240 ++++++++++++ ...flowExtensionDevServerIntegrationTest.java | 73 ++++ 22 files changed, 2219 insertions(+), 330 deletions(-) create mode 100644 temporal-testing/src/main/java/io/temporal/testing/TemporalDevServer.java create mode 100644 temporal-testing/src/main/java/io/temporal/testing/TemporalDevServerOptions.java create mode 100644 temporal-testing/src/main/java/io/temporal/testing/internal/DevServerTestPreparation.java create mode 100644 temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java create mode 100644 temporal-testing/src/main/java/io/temporal/testing/internal/devserver/TemporalDevServerDownloader.java create mode 100644 temporal-testing/src/main/java/io/temporal/testing/internal/devserver/TemporalDevServerLauncher.java create mode 100644 temporal-testing/src/test/java/io/temporal/testing/TemporalDevServerIntegrationTest.java create mode 100644 temporal-testing/src/test/java/io/temporal/testing/TemporalDevServerOptionsTest.java create mode 100644 temporal-testing/src/test/java/io/temporal/testing/internal/devserver/TemporalDevServerDownloaderTest.java create mode 100644 temporal-testing/src/test/java/io/temporal/testing/junit5/TestWorkflowExtensionDevServerIntegrationTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa69f55f5b..1c147f7e9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,8 +82,8 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 - - name: Prepare CLI-backed tests - run: ./gradlew --no-daemon prepareTemporalCliTests -x spotlessCheck -x spotlessApply -x spotlessJava + - name: Prepare dev-server tests + run: ./gradlew --no-daemon prepareDevServerTests # Can't actually run tests against Java 8 because Mockito 5 requires Java 11+. # We therefore have to rely on the fact that the code has been compiled with @@ -92,7 +92,7 @@ jobs: - name: Run unit tests (Java 11) env: USER: unittest - run: ./gradlew --no-daemon --offline test -x spotlessCheck -x spotlessApply -x spotlessJava -PtestJavaVersion=11 -PuseTemporalCli + run: ./gradlew --no-daemon --offline test -PtestJavaVersion=11 -PtestServer=dev-server - name: Run Jackson 3 converter tests (Java 17) env: @@ -103,7 +103,7 @@ jobs: - name: Run virtual thread tests (Java 21) env: USER: unittest - run: ./gradlew --no-daemon --offline :temporal-sdk:virtualThreadTests -x spotlessCheck -x spotlessApply -x spotlessJava -PtestJavaVersion=21 -PuseTemporalCli + run: ./gradlew --no-daemon --offline :temporal-sdk:virtualThreadTests -PtestJavaVersion=21 -PtestServer=dev-server - name: Publish Test Report uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ee135ed21a..d5b8881133 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -79,30 +79,20 @@ skipped. Gradle can download the pinned Temporal CLI, start a correctly configur dev server, wait for it to become ready, configure the tests to use it, and stop it when the test invocation finishes. -Prepare the CLI-backed tests with: +Run the suite against a managed local Temporal dev server with: ```bash -./gradlew prepareTemporalCliTests +./gradlew test -PtestJavaVersion=11 -PtestServer=dev-server ``` -This caches the platform-specific CLI under the Gradle user home and resolves the -build and test dependencies needed by the Java 11 unit tests and Java 21 -virtual-thread tests. Run the CLI-backed CI coverage with: +Normal Gradle test filtering works, so a single dev-server-backed test can be run with: ```bash -./gradlew test -PtestJavaVersion=11 -PuseTemporalCli -./gradlew :temporal-sdk:virtualThreadTests -PtestJavaVersion=21 -PuseTemporalCli -``` - -Normal Gradle test filtering works, so a single CLI-backed test can be reproduced: - -```bash -./gradlew :temporal-sdk:test -PtestJavaVersion=11 -PuseTemporalCli \ +./gradlew :temporal-sdk:test -PtestJavaVersion=11 -PtestServer=dev-server \ --tests "io.temporal.activity.ActivityPauseTest.activityPause" ``` -Java 11 and Java 21 installations must be available to Gradle for the corresponding -commands. Server output is written to `build/temporal-cli/server/server.log`. +Java 11 must be available to Gradle for these commands. ## Things to Avoid diff --git a/gradle/temporalCli.gradle b/gradle/temporalCli.gradle index 377cbc8762..6ceed2b449 100644 --- a/gradle/temporalCli.gradle +++ b/gradle/temporalCli.gradle @@ -1,241 +1,37 @@ -import java.util.concurrent.TimeUnit -import org.gradle.api.GradleException -import org.gradle.api.file.DirectoryProperty -import org.gradle.api.file.RegularFileProperty -import org.gradle.api.logging.Logging -import org.gradle.api.provider.ListProperty -import org.gradle.api.provider.Property import org.gradle.api.services.BuildService import org.gradle.api.services.BuildServiceParameters import org.gradle.api.tasks.JavaExec import org.gradle.api.tasks.testing.Test -abstract class TemporalCliServerService - implements BuildService, AutoCloseable { - interface Parameters extends BuildServiceParameters { - RegularFileProperty getExecutable() - - DirectoryProperty getWorkingDirectory() - - ListProperty getArguments() - - Property getReadinessUrl() - } - - private static final def logger = Logging.getLogger(TemporalCliServerService) - private Process serverProcess - - synchronized void start() { - if (serverProcess != null && serverProcess.isAlive()) { - return - } - - File executable = parameters.executable.get().asFile - File workingDirectory = parameters.workingDirectory.get().asFile - workingDirectory.mkdirs() - cleanDatabase(workingDirectory) - File logFile = new File(workingDirectory, 'server.log') - logFile.delete() - - logger.lifecycle("Starting the Temporal CLI dev server. Output: ${logFile}") - serverProcess = new ProcessBuilder( - [executable.absolutePath] + parameters.arguments.get()) - .directory(workingDirectory) - .redirectErrorStream(true) - .redirectOutput(ProcessBuilder.Redirect.to(logFile)) - .start() - - try { - waitUntilReady(logFile) - } catch (Throwable failure) { - stopServer(workingDirectory) - throw failure - } - } - - private void waitUntilReady(File logFile) { - Process readinessCheck = new ProcessBuilder( - 'curl', - '--fail', '--silent', '--show-error', - '--retry', '60', - '--retry-all-errors', - '--retry-connrefused', - '--retry-delay', '1', - '--retry-max-time', '60', - '--connect-timeout', '1', - '--max-time', '2', - parameters.readinessUrl.get()) - .redirectErrorStream(true) - .start() - String readinessOutput = readinessCheck.inputStream.getText('UTF-8').trim() - if (readinessCheck.waitFor() != 0) { - throw readinessFailure( - "Temporal CLI readiness check failed: ${readinessOutput}", logFile) - } - if (!serverProcess.isAlive()) { - throw readinessFailure( - "The Temporal CLI dev server exited with code ${serverProcess.exitValue()}.", - logFile) - } - logger.lifecycle("Temporal CLI dev server is ready at ${parameters.readinessUrl.get()}.") - } - - private static GradleException readinessFailure(String message, File logFile) { - String serverOutput = logFile.isFile() - ? logFile.readLines('UTF-8').takeRight(200).join(System.lineSeparator()) - : '' - return new GradleException( - "${message}\nTemporal CLI output (${logFile}):\n${serverOutput}") - } - +abstract class TemporalDevServerTestSemaphore + implements BuildService, AutoCloseable { @Override - synchronized void close() { - stopServer(parameters.workingDirectory.get().asFile) - } - - private void stopServer(File workingDirectory) { - if (serverProcess != null && serverProcess.isAlive()) { - logger.lifecycle('Stopping the Temporal CLI dev server.') - serverProcess.destroy() - if (!serverProcess.waitFor(10, TimeUnit.SECONDS)) { - serverProcess.destroyForcibly() - serverProcess.waitFor(10, TimeUnit.SECONDS) - } - } - serverProcess = null - cleanDatabase(workingDirectory) - } - - private static void cleanDatabase(File workingDirectory) { - ['temporal.sqlite', 'temporal.sqlite-shm', 'temporal.sqlite-wal'].each { name -> - File databaseFile = new File(workingDirectory, name) - if (databaseFile.exists() && !databaseFile.delete()) { - logger.warn("Unable to delete Temporal CLI database file ${databaseFile}.") - } - } - } -} - -ext.temporalCliVersion = '1.7.2-standalone-nexus-operations' - -def osName = System.getProperty('os.name').toLowerCase(Locale.ROOT) -def temporalCliOs = osName.contains('mac') || osName.contains('darwin') - ? 'darwin' - : osName.contains('windows') ? 'windows' : 'linux' -def architecture = System.getProperty('os.arch').toLowerCase(Locale.ROOT) -def temporalCliArch = architecture in ['arm64', 'aarch64'] ? 'arm64' : 'amd64' -def temporalCliExecutableName = temporalCliOs == 'windows' ? 'temporal.exe' : 'temporal' -def temporalCliArchiveExtension = temporalCliOs == 'windows' ? 'zip' : 'tar.gz' -def temporalCliClassifier = "${temporalCliOs}_${temporalCliArch}" -def temporalCliCache = file( - "${gradle.gradleUserHomeDir}/caches/temporal-cli/${temporalCliVersion}/${temporalCliClassifier}") -def temporalCliExecutable = file("${temporalCliCache}/${temporalCliExecutableName}") -def temporalCliArchive = file( - "${temporalCliCache}/temporal_cli_${temporalCliVersion}_${temporalCliClassifier}.${temporalCliArchiveExtension}") -def temporalCliUrl = - "https://github.com/temporalio/cli/releases/download/v${temporalCliVersion}/${temporalCliArchive.name}" - -def downloadTemporalCliArchive = tasks.register('downloadTemporalCliArchive', Exec) { - onlyIf { - !temporalCliExecutable.isFile() && !temporalCliArchive.isFile() - } - doFirst { - if (gradle.startParameter.offline) { - throw new GradleException( - "Temporal CLI ${temporalCliVersion} for ${temporalCliClassifier} is not cached " - + "at ${temporalCliExecutable}. Run './gradlew prepareTemporalCliTests' " - + "before using Gradle offline.") - } - temporalCliCache.mkdirs() - } - commandLine( - 'curl', '--fail', '--location', '--retry', '3', - '--output', temporalCliArchive.absolutePath, temporalCliUrl) - outputs.file(temporalCliArchive) + void close() {} } -def extractTemporalCli = tasks.register('extractTemporalCli', Exec) { - dependsOn(downloadTemporalCliArchive) - onlyIf { - !temporalCliExecutable.isFile() - } - doFirst { - temporalCliCache.mkdirs() - } - commandLine( - 'tar', '-xf', temporalCliArchive.absolutePath, - '-C', temporalCliCache.absolutePath, temporalCliExecutableName) - outputs.file(temporalCliExecutable) -} +def devServerProfile = providers.gradleProperty('testServer') + .map { it == 'dev-server' } + .orElse(false) +def devServerCache = + file("${gradle.gradleUserHomeDir}/caches/temporal-dev-server") +def devServerWorkingDirectory = layout.buildDirectory.dir('temporal-cli/server') -def downloadTemporalCli = tasks.register('downloadTemporalCli') { - group = 'verification' - description = "Downloads and caches Temporal CLI ${temporalCliVersion} for this platform." - dependsOn(extractTemporalCli) - doLast { - if (!temporalCliExecutable.isFile()) { - throw new GradleException( - "Temporal CLI archive did not contain ${temporalCliExecutableName}.") - } - if (temporalCliOs != 'windows' && !temporalCliExecutable.setExecutable(true)) { - throw new GradleException("Unable to make ${temporalCliExecutable} executable.") - } - } +def devServerSemaphore = gradle.sharedServices.registerIfAbsent( + 'temporalDevServerTestSemaphore', TemporalDevServerTestSemaphore) { + maxParallelUsages.set(1) } -def temporalServiceAddress = 'localhost:7233' -def temporalCliServerArguments = [ - 'server', 'start-dev', - '--headless', - '--port', '7233', - '--http-port', '7243', - '--namespace', 'UnitTest', - '--db-filename', 'temporal.sqlite', - '--sqlite-pragma', 'journal_mode=WAL', - '--sqlite-pragma', 'synchronous=OFF', - '--search-attribute', 'CustomKeywordField=Keyword', - '--search-attribute', 'CustomStringField=Text', - '--search-attribute', 'CustomTextField=Text', - '--search-attribute', 'CustomIntField=Int', - '--search-attribute', 'CustomDatetimeField=Datetime', - '--search-attribute', 'CustomDoubleField=Double', - '--search-attribute', 'CustomBoolField=Bool', - '--dynamic-config-value', 'system.enableActivityEagerExecution=true', - '--dynamic-config-value', 'history.MaxBufferedQueryCount=10000', - '--dynamic-config-value', 'frontend.workerVersioningDataAPIs=true', - '--dynamic-config-value', 'history.enableRequestIdRefLinks=true', - '--dynamic-config-value', 'frontend.WorkerHeartbeatsEnabled=true', - '--dynamic-config-value', 'frontend.ListWorkersEnabled=true', - '--dynamic-config-value', 'frontend.enableCancelWorkerPollsOnShutdown=true', - '--dynamic-config-value', - 'component.callbacks.allowedAddresses=[{"Pattern":"localhost:7243","AllowInsecure":true}]', - '--dynamic-config-value', - 'callback.allowedAddresses=[{"Pattern":"localhost:7243","AllowInsecure":true}]', - '--dynamic-config-value', 'frontend.activityAPIsEnabled=true', - '--dynamic-config-value', 'activity.enableStandalone=true', - '--dynamic-config-value', 'activity.startDelayEnabled=true', - '--dynamic-config-value', 'nexusoperation.enableStandalone=true', - '--dynamic-config-value', 'history.enableChasm=true', - '--dynamic-config-value', 'history.enableCHASMSignalBacklinks=true', - '--dynamic-config-value', 'history.enableTransitionHistory=true', - '--dynamic-config-value', 'frontend.enableCancelWorkerPollsOnShutdown=true', - '--dynamic-config-value', 'frontend.workerCommandsEnabled=true', - '--dynamic-config-value', 'system.enableCancelActivityWorkerCommand=true' -] - -def temporalCliServer = gradle.sharedServices.registerIfAbsent( - 'temporalCliServer', TemporalCliServerService) { - parameters.executable.fileValue(temporalCliExecutable) - parameters.workingDirectory.set(layout.buildDirectory.dir('temporal-cli/server')) - parameters.arguments.set(temporalCliServerArguments) - parameters.readinessUrl.set('http://127.0.0.1:7243/api/v1/namespaces/UnitTest') -} - -def prepareTemporalCliTests = tasks.register('prepareTemporalCliTests') { +def prepareDevServerTests = tasks.register('prepareDevServerTests', JavaExec) { group = 'verification' description = - 'Caches Temporal CLI and all build inputs required by the CLI-backed test suites.' - dependsOn(downloadTemporalCli) + 'Caches the repository dev server and resolves inputs required by dev-server tests.' + getMainClass().set('io.temporal.testing.internal.DevServerTestPreparation') + systemProperty( + 'io.temporal.testing.internal.devServerDownloadDestination', + devServerCache.absolutePath) + systemProperty( + 'io.temporal.testing.internal.devServerDownloadEnabled', + 'true') } gradle.projectsEvaluated { @@ -243,7 +39,9 @@ gradle.projectsEvaluated { subproject.tasks.findByName('test') }.findAll { task -> task instanceof Test } as List - prepareTemporalCliTests.configure { + prepareDevServerTests.configure { + classpath = project(':temporal-testing').sourceSets.main.runtimeClasspath + dependsOn project(':temporal-testing').tasks.named('classes') dependsOn standardTestTasks.collect { testTask -> testTask.project.tasks.named('testClasses') } @@ -256,32 +54,35 @@ gradle.projectsEvaluated { } } - if (!project.hasProperty('useTemporalCli') - || project.property('useTemporalCli').toString() == 'false') { + if (!devServerProfile.get()) { return } allprojects.each { candidateProject -> candidateProject.tasks.withType(Test).configureEach { - dependsOn(downloadTemporalCli) - usesService(temporalCliServer) - environment('TEMPORAL_SERVICE_ADDRESS', temporalServiceAddress) + usesService(devServerSemaphore) + forkEvery = 0 + maxParallelForks = 1 environment('USE_EXTERNAL_SERVICE', 'true') - doFirst { - temporalCliServer.get().start() - } + environment('TEMPORAL_SERVICE_ADDRESS', 'localhost:7233') + systemProperty( + 'io.temporal.testing.internal.devServerProfile', + 'true') + systemProperty( + 'io.temporal.testing.internal.devServerDownloadDestination', + devServerCache.absolutePath) + systemProperty( + 'io.temporal.testing.internal.devServerDownloadEnabled', + (!gradle.startParameter.offline).toString()) + systemProperty( + 'io.temporal.testing.internal.devServerWorkingDirectory', + devServerWorkingDirectory.get().asFile.absolutePath) } candidateProject.tasks.withType(JavaExec).matching { it.name == 'registerNamespace' }.configureEach { - dependsOn(downloadTemporalCli) - usesService(temporalCliServer) - environment('TEMPORAL_SERVICE_ADDRESS', temporalServiceAddress) - environment('USE_EXTERNAL_SERVICE', 'true') - doFirst { - temporalCliServer.get().start() - } + onlyIf { false } } } } diff --git a/temporal-sdk/src/test/java/io/temporal/worker/StickyWorkerTest.java b/temporal-sdk/src/test/java/io/temporal/worker/StickyWorkerTest.java index b80d3489ab..4a900476fd 100644 --- a/temporal-sdk/src/test/java/io/temporal/worker/StickyWorkerTest.java +++ b/temporal-sdk/src/test/java/io/temporal/worker/StickyWorkerTest.java @@ -21,6 +21,7 @@ import io.temporal.serviceclient.MetricsTag; import io.temporal.testing.TestEnvironmentOptions; import io.temporal.testing.TestWorkflowEnvironment; +import io.temporal.testing.internal.ExternalServiceTestConfigurator; import io.temporal.testing.internal.SDKTestWorkflowRule; import io.temporal.workflow.Async; import io.temporal.workflow.CompletablePromise; @@ -52,8 +53,9 @@ public class StickyWorkerTest { private static final boolean useExternalService = - Boolean.parseBoolean(System.getenv("USE_EXTERNAL_SERVICE")); - private static final String serviceAddress = System.getenv("TEMPORAL_SERVICE_ADDRESS"); + ExternalServiceTestConfigurator.isUseExternalService(); + private static final String serviceAddress = + ExternalServiceTestConfigurator.getTemporalServiceAddress(); @Rule public TestName testName = new TestName(); diff --git a/temporal-sdk/src/test/java/io/temporal/workerFactory/WorkerFactoryTests.java b/temporal-sdk/src/test/java/io/temporal/workerFactory/WorkerFactoryTests.java index 3b7974d459..1fb26f3452 100644 --- a/temporal-sdk/src/test/java/io/temporal/workerFactory/WorkerFactoryTests.java +++ b/temporal-sdk/src/test/java/io/temporal/workerFactory/WorkerFactoryTests.java @@ -11,6 +11,7 @@ import io.temporal.client.WorkflowClientOptions; import io.temporal.serviceclient.WorkflowServiceStubs; import io.temporal.serviceclient.WorkflowServiceStubsOptions; +import io.temporal.testing.internal.ExternalServiceTestConfigurator; import io.temporal.worker.WorkerFactory; import java.util.concurrent.TimeUnit; import org.junit.After; @@ -22,8 +23,9 @@ public class WorkerFactoryTests { private static final boolean useExternalService = - Boolean.parseBoolean(System.getenv("USE_EXTERNAL_SERVICE")); - private static final String serviceAddress = System.getenv("TEMPORAL_SERVICE_ADDRESS"); + ExternalServiceTestConfigurator.isUseExternalService(); + private static final String serviceAddress = + ExternalServiceTestConfigurator.getTemporalServiceAddress(); @BeforeClass public static void beforeClass() { diff --git a/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/WorkerVersioningTest.java b/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/WorkerVersioningTest.java index 70cc4076bd..8f57f89ad4 100644 --- a/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/WorkerVersioningTest.java +++ b/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/WorkerVersioningTest.java @@ -13,6 +13,7 @@ import io.temporal.common.WorkflowExecutionHistory; import io.temporal.spring.boot.autoconfigure.workerversioning.TestWorkflow; import io.temporal.spring.boot.autoconfigure.workerversioning.TestWorkflow2; +import io.temporal.testing.internal.ExternalServiceTestConfigurator; import io.temporal.worker.WorkerFactory; import java.time.Duration; import org.junit.jupiter.api.Assumptions; @@ -32,15 +33,23 @@ @ActiveProfiles(profiles = {"worker-versioning", "disable-start-workers"}) @TestInstance(TestInstance.Lifecycle.PER_CLASS) public class WorkerVersioningTest { + private static final boolean useExternalService = initializeExternalService(); + @Autowired ConfigurableApplicationContext applicationContext; @Autowired WorkflowClient workflowClient; @BeforeAll static void checkExternalService() { - String useExternal = System.getenv("USE_EXTERNAL_SERVICE"); Assumptions.assumeTrue( - useExternal != null && useExternal.equalsIgnoreCase("true"), - "Skipping tests because USE_EXTERNAL_SERVICE is not set"); + useExternalService, "Skipping tests because USE_EXTERNAL_SERVICE is not set"); + } + + private static boolean initializeExternalService() { + boolean useExternalService = ExternalServiceTestConfigurator.isUseExternalService(); + if (useExternalService) { + ExternalServiceTestConfigurator.getTemporalServiceAddress(); + } + return useExternalService; } @BeforeEach diff --git a/temporal-testing/build.gradle b/temporal-testing/build.gradle index 606f88fccd..f9ca013456 100644 --- a/temporal-testing/build.gradle +++ b/temporal-testing/build.gradle @@ -17,6 +17,8 @@ dependencies { api project(':temporal-sdk') api project(':temporal-test-server') + implementation 'org.apache.commons:commons-compress:1.28.0' + // This dependency is included in temporal-sdk module as optional with compileOnly scope. // To make things easier for users, it's helpful for the testing module to bring this dependency // transitively as most users work with history jsons in tests. diff --git a/temporal-testing/src/main/java/io/temporal/testing/TemporalDevServer.java b/temporal-testing/src/main/java/io/temporal/testing/TemporalDevServer.java new file mode 100644 index 0000000000..1bc1709351 --- /dev/null +++ b/temporal-testing/src/main/java/io/temporal/testing/TemporalDevServer.java @@ -0,0 +1,74 @@ +package io.temporal.testing; + +import io.temporal.common.Experimental; +import io.temporal.testing.internal.devserver.TemporalDevServerLauncher; + +/** + * A local Temporal dev server owned by the calling process. + * + *
{@code
+ * try (TemporalDevServer server = TemporalDevServer.start()) {
+ *   WorkflowServiceStubs stubs =
+ *       WorkflowServiceStubs.newServiceStubs(
+ *           WorkflowServiceStubsOptions.newBuilder().setTarget(server.getTarget()).build());
+ *   // Use stubs against server.getNamespace().
+ * }
+ * }
+ */ +@Experimental +public final class TemporalDevServer implements AutoCloseable { + private final String target; + private final String namespace; + private final AutoCloseable owner; + + private TemporalDevServer(String target, String namespace, AutoCloseable owner) { + this.target = target; + this.namespace = namespace; + this.owner = owner; + } + + /** Starts a dev server in namespace {@code default} with default options. */ + public static TemporalDevServer start() { + return start("default", TemporalDevServerOptions.getDefaultInstance()); + } + + /** Starts a dev server in namespace {@code default} with the supplied options. */ + public static TemporalDevServer start(TemporalDevServerOptions options) { + return start("default", options); + } + + /** Starts a dev server for the supplied namespace and options. */ + public static TemporalDevServer start(String namespace, TemporalDevServerOptions options) { + if (namespace == null || namespace.trim().isEmpty()) { + throw new IllegalArgumentException("namespace cannot be blank"); + } + if (options == null) { + throw new NullPointerException("options"); + } + TemporalDevServerLauncher.RunningServer running = + TemporalDevServerLauncher.start(namespace, options); + return new TemporalDevServer(running.getTarget(), namespace, running); + } + + /** Returns the usable {@code host:port} gRPC target. */ + public String getTarget() { + return target; + } + + /** Returns the namespace created by the dev server. */ + public String getNamespace() { + return namespace; + } + + /** Stops the owned process. This method is idempotent. */ + @Override + public void close() { + try { + owner.close(); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException("Failed stopping Temporal dev server at " + target, e); + } + } +} diff --git a/temporal-testing/src/main/java/io/temporal/testing/TemporalDevServerOptions.java b/temporal-testing/src/main/java/io/temporal/testing/TemporalDevServerOptions.java new file mode 100644 index 0000000000..7bd90cd8e5 --- /dev/null +++ b/temporal-testing/src/main/java/io/temporal/testing/TemporalDevServerOptions.java @@ -0,0 +1,369 @@ +package io.temporal.testing; + +import io.temporal.common.Experimental; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import javax.annotation.Nullable; + +/** Options for a {@link TemporalDevServer}. */ +@Experimental +public final class TemporalDevServerOptions { + private static final TemporalDevServerOptions DEFAULT_INSTANCE = newBuilder().build(); + + public static Builder newBuilder() { + return new Builder(); + } + + public static Builder newBuilder(TemporalDevServerOptions options) { + return new Builder(options); + } + + public static TemporalDevServerOptions getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + public static final class Builder { + private String existingPath; + private String downloadVersion = "default"; + private String downloadDestination; + private Duration downloadCacheTtl; + private boolean downloadEnabled = true; + private String ip = "127.0.0.1"; + private Integer port; + private String databaseFilename; + private boolean uiEnabled; + private Integer uiPort; + private String logFormat = "pretty"; + private String logLevel = "warn"; + private String workingDirectory; + private String logFile; + private Duration startupTimeout = Duration.ofSeconds(60); + private List extraArgs = new ArrayList<>(); + + private Builder() {} + + private Builder(TemporalDevServerOptions options) { + if (options == null) { + throw new NullPointerException("options"); + } + this.existingPath = options.existingPath; + this.downloadVersion = options.downloadVersion; + this.downloadDestination = options.downloadDestination; + this.downloadCacheTtl = options.downloadCacheTtl; + this.downloadEnabled = options.downloadEnabled; + this.ip = options.ip; + this.port = options.port; + this.databaseFilename = options.databaseFilename; + this.uiEnabled = options.uiEnabled; + this.uiPort = options.uiPort; + this.logFormat = options.logFormat; + this.logLevel = options.logLevel; + this.workingDirectory = options.workingDirectory; + this.logFile = options.logFile; + this.startupTimeout = options.startupTimeout; + this.extraArgs = new ArrayList<>(options.extraArgs); + } + + /** Sets an existing Temporal CLI executable instead of using the download cache. */ + public Builder setExistingPath(@Nullable String existingPath) { + this.existingPath = existingPath; + return this; + } + + /** + * Sets the CLI version to download. {@code "default"} selects the version associated with the + * running sdk-java version; any other non-empty value is sent to temporal.download unchanged. + */ + public Builder setDownloadVersion(String downloadVersion) { + this.downloadVersion = downloadVersion; + return this; + } + + /** Sets the root directory for cached downloads. Defaults to the JVM temporary directory. */ + public Builder setDownloadDestination(@Nullable String downloadDestination) { + this.downloadDestination = downloadDestination; + return this; + } + + /** Sets the maximum age of a cached executable. A null value caches indefinitely. */ + public Builder setDownloadCacheTtl(@Nullable Duration downloadCacheTtl) { + this.downloadCacheTtl = downloadCacheTtl; + return this; + } + + /** Alias for {@link #setDownloadCacheTtl(Duration)}. */ + public Builder setDownloadTtl(@Nullable Duration downloadCacheTtl) { + return setDownloadCacheTtl(downloadCacheTtl); + } + + /** Sets whether a missing or expired executable may be downloaded. */ + public Builder setDownloadEnabled(boolean downloadEnabled) { + this.downloadEnabled = downloadEnabled; + return this; + } + + /** Sets the IP address on which the dev server listens. */ + public Builder setIp(String ip) { + this.ip = ip; + return this; + } + + /** Alias for {@link #setIp(String)}. */ + public Builder setBindIp(String ip) { + return setIp(ip); + } + + /** Sets the gRPC port. A null value asks the OS for an available port. */ + public Builder setPort(@Nullable Integer port) { + this.port = port; + return this; + } + + /** Sets an SQLite database filename. A null value uses in-memory SQLite. */ + public Builder setDatabaseFilename(@Nullable String databaseFilename) { + this.databaseFilename = databaseFilename; + return this; + } + + /** Sets whether the Temporal UI is enabled. */ + public Builder setUiEnabled(boolean uiEnabled) { + this.uiEnabled = uiEnabled; + return this; + } + + /** Alias for {@link #setUiEnabled(boolean)}. */ + public Builder setUi(boolean uiEnabled) { + return setUiEnabled(uiEnabled); + } + + /** Sets the UI port and implicitly enables the UI. */ + public Builder setUiPort(@Nullable Integer uiPort) { + this.uiPort = uiPort; + if (uiPort != null) { + this.uiEnabled = true; + } + return this; + } + + /** Sets the Temporal CLI log format. Defaults to {@code pretty}. */ + public Builder setLogFormat(String logFormat) { + this.logFormat = logFormat; + return this; + } + + /** Sets the Temporal CLI log level. Defaults to {@code warn}. */ + public Builder setLogLevel(String logLevel) { + this.logLevel = logLevel; + return this; + } + + /** Sets the child process working directory. Defaults to the current working directory. */ + public Builder setWorkingDirectory(@Nullable String workingDirectory) { + this.workingDirectory = workingDirectory; + return this; + } + + /** Sets a file that receives server output. A null value inherits the parent output. */ + public Builder setLogFile(@Nullable String logFile) { + this.logFile = logFile; + return this; + } + + /** Sets the single timeout used for health and namespace readiness checks. */ + public Builder setStartupTimeout(Duration startupTimeout) { + this.startupTimeout = startupTimeout; + return this; + } + + /** Sets additional arguments appended to the generated {@code server start-dev} command. */ + public Builder setExtraArgs(List extraArgs) { + if (extraArgs == null) { + throw new NullPointerException("extraArgs"); + } + this.extraArgs = new ArrayList<>(extraArgs); + return this; + } + + /** Sets additional arguments appended to the generated {@code server start-dev} command. */ + public Builder setExtraArgs(String... extraArgs) { + if (extraArgs == null) { + throw new NullPointerException("extraArgs"); + } + this.extraArgs = new ArrayList<>(); + Collections.addAll(this.extraArgs, extraArgs); + return this; + } + + public TemporalDevServerOptions build() { + requireNonBlank(downloadVersion, "downloadVersion"); + requireNonBlank(ip, "ip"); + validatePort(port, "port"); + validatePort(uiPort, "uiPort"); + requireNonBlank(logFormat, "logFormat"); + requireNonBlank(logLevel, "logLevel"); + if (existingPath != null) { + requireNonBlank(existingPath, "existingPath"); + } + if (downloadDestination != null) { + requireNonBlank(downloadDestination, "downloadDestination"); + } + if (databaseFilename != null) { + requireNonBlank(databaseFilename, "databaseFilename"); + } + if (workingDirectory != null) { + requireNonBlank(workingDirectory, "workingDirectory"); + } + if (logFile != null) { + requireNonBlank(logFile, "logFile"); + } + if (downloadCacheTtl != null && downloadCacheTtl.isNegative()) { + throw new IllegalArgumentException("downloadCacheTtl cannot be negative"); + } + if (startupTimeout == null || startupTimeout.isZero() || startupTimeout.isNegative()) { + throw new IllegalArgumentException("startupTimeout must be positive"); + } + for (String arg : extraArgs) { + if (arg == null) { + throw new IllegalArgumentException("extraArgs cannot contain null"); + } + if (arg.indexOf('\n') >= 0 || arg.indexOf('\r') >= 0) { + throw new IllegalArgumentException("extraArgs cannot contain newlines"); + } + } + return new TemporalDevServerOptions(this); + } + + private static void requireNonBlank(String value, String name) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException(name + " cannot be blank"); + } + } + + private static void validatePort(Integer port, String name) { + if (port != null && (port < 1 || port > 65535)) { + throw new IllegalArgumentException(name + " must be between 1 and 65535"); + } + } + } + + private final String existingPath; + private final String downloadVersion; + private final String downloadDestination; + private final Duration downloadCacheTtl; + private final boolean downloadEnabled; + private final String ip; + private final Integer port; + private final String databaseFilename; + private final boolean uiEnabled; + private final Integer uiPort; + private final String logFormat; + private final String logLevel; + private final String workingDirectory; + private final String logFile; + private final Duration startupTimeout; + private final List extraArgs; + + private TemporalDevServerOptions(Builder builder) { + this.existingPath = builder.existingPath; + this.downloadVersion = builder.downloadVersion; + this.downloadDestination = builder.downloadDestination; + this.downloadCacheTtl = builder.downloadCacheTtl; + this.downloadEnabled = builder.downloadEnabled; + this.ip = builder.ip; + this.port = builder.port; + this.databaseFilename = builder.databaseFilename; + this.uiEnabled = builder.uiEnabled; + this.uiPort = builder.uiPort; + this.logFormat = builder.logFormat; + this.logLevel = builder.logLevel; + this.workingDirectory = builder.workingDirectory; + this.logFile = builder.logFile; + this.startupTimeout = builder.startupTimeout; + this.extraArgs = Collections.unmodifiableList(new ArrayList<>(builder.extraArgs)); + } + + @Nullable + public String getExistingPath() { + return existingPath; + } + + public String getDownloadVersion() { + return downloadVersion; + } + + @Nullable + public String getDownloadDestination() { + return downloadDestination; + } + + @Nullable + public Duration getDownloadCacheTtl() { + return downloadCacheTtl; + } + + /** Alias for {@link #getDownloadCacheTtl()}. */ + @Nullable + public Duration getDownloadTtl() { + return downloadCacheTtl; + } + + public boolean isDownloadEnabled() { + return downloadEnabled; + } + + public String getIp() { + return ip; + } + + /** Alias for {@link #getIp()}. */ + public String getBindIp() { + return ip; + } + + @Nullable + public Integer getPort() { + return port; + } + + @Nullable + public String getDatabaseFilename() { + return databaseFilename; + } + + public boolean isUiEnabled() { + return uiEnabled; + } + + @Nullable + public Integer getUiPort() { + return uiPort; + } + + public String getLogFormat() { + return logFormat; + } + + public String getLogLevel() { + return logLevel; + } + + @Nullable + public String getWorkingDirectory() { + return workingDirectory; + } + + @Nullable + public String getLogFile() { + return logFile; + } + + public Duration getStartupTimeout() { + return startupTimeout; + } + + public List getExtraArgs() { + return extraArgs; + } +} diff --git a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironment.java b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironment.java index 24090982e3..d3f3adaac8 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironment.java +++ b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironment.java @@ -5,6 +5,7 @@ import io.temporal.api.nexus.v1.Endpoint; import io.temporal.client.ActivityClient; import io.temporal.client.WorkflowClient; +import io.temporal.common.Experimental; import io.temporal.common.WorkflowExecutionHistory; import io.temporal.serviceclient.OperatorServiceStubs; import io.temporal.serviceclient.WorkflowServiceStubs; @@ -88,6 +89,77 @@ static TestWorkflowEnvironment newInstance(TestEnvironmentOptions options) { return new TestWorkflowEnvironmentInternal(options); } + /** + * Starts a local Temporal dev server and returns an environment that owns it. + * + *

Unlike the in-memory test server, a local dev-server environment does not support time + * skipping. + * + *

{@code
+   * try (TestWorkflowEnvironment environment = TestWorkflowEnvironment.startLocal()) {
+   *   Worker worker = environment.newWorker("test-task-queue");
+   *   // Register implementations and run workflows against the local dev server.
+   * }
+   * }
+ */ + @Experimental + static TestWorkflowEnvironment startLocal() { + return startLocal( + TestEnvironmentOptions.getDefaultInstance(), TemporalDevServerOptions.getDefaultInstance()); + } + + /** + * Starts a local Temporal dev server using the environment namespace and returns an environment + * that owns it. Local dev-server environments do not support time skipping. + */ + @Experimental + static TestWorkflowEnvironment startLocal(TestEnvironmentOptions testOptions) { + return startLocal(testOptions, TemporalDevServerOptions.getDefaultInstance()); + } + + /** + * Starts a local Temporal dev server with the supplied server options. Local dev-server + * environments do not support time skipping. + */ + @Experimental + static TestWorkflowEnvironment startLocal(TemporalDevServerOptions serverOptions) { + return startLocal(TestEnvironmentOptions.getDefaultInstance(), serverOptions); + } + + /** + * Starts a local Temporal dev server and returns an environment that owns it. + * + *

The namespace in {@code testOptions} is authoritative and is created by the dev server. + * Local dev-server environments do not support time skipping. + */ + @Experimental + static TestWorkflowEnvironment startLocal( + TestEnvironmentOptions testOptions, TemporalDevServerOptions serverOptions) { + if (testOptions == null) { + testOptions = TestEnvironmentOptions.getDefaultInstance(); + } + TestEnvironmentOptions validated = + TestEnvironmentOptions.newBuilder(testOptions).validateAndBuildWithDefaults(); + String namespace = validated.getWorkflowClientOptions().getNamespace(); + TemporalDevServer server = TemporalDevServer.start(namespace, serverOptions); + try { + TestEnvironmentOptions localOptions = + TestEnvironmentOptions.newBuilder(validated) + .setUseExternalService(true) + .setUseTimeskipping(false) + .setTarget(server.getTarget()) + .build(); + return new TestWorkflowEnvironmentInternal(localOptions, server); + } catch (RuntimeException | Error failure) { + try { + server.close(); + } catch (RuntimeException | Error cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + throw failure; + } + } + /** * Creates a new Worker instance that is connected to the in-memory test Temporal service. * diff --git a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironmentInternal.java b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironmentInternal.java index a24f1e3172..8b54f078bd 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironmentInternal.java +++ b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironmentInternal.java @@ -49,8 +49,16 @@ public final class TestWorkflowEnvironmentInternal implements TestWorkflowEnviro private final WorkerFactory workerFactory; private final @Nullable TimeLockingInterceptor timeLockingInterceptor; private final IdempotentTimeLocker constructorTimeLock; + private final @Nullable TemporalDevServer ownedDevServer; public TestWorkflowEnvironmentInternal(@Nullable TestEnvironmentOptions testEnvironmentOptions) { + this(testEnvironmentOptions, null); + } + + TestWorkflowEnvironmentInternal( + @Nullable TestEnvironmentOptions testEnvironmentOptions, + @Nullable TemporalDevServer ownedDevServer) { + this.ownedDevServer = ownedDevServer; if (testEnvironmentOptions == null) { testEnvironmentOptions = TestEnvironmentOptions.getDefaultInstance(); } @@ -299,24 +307,49 @@ public WorkflowExecutionHistory getWorkflowExecutionHistory( @Override public void close() { - if (testServiceStubs != null) { - testServiceStubs.shutdownNow(); - } - operatorServiceStubs.shutdownNow(); - workerFactory.shutdownNow(); - workerFactory.awaitTermination(10, TimeUnit.SECONDS); - if (constructorTimeLock != null) { - constructorTimeLock.unlockTimeSkipping(); + RuntimeException failure = null; + try { + if (testServiceStubs != null) { + failure = runCleanup(failure, testServiceStubs::shutdownNow); + } + failure = runCleanup(failure, operatorServiceStubs::shutdownNow); + failure = runCleanup(failure, workerFactory::shutdownNow); + failure = runCleanup(failure, () -> workerFactory.awaitTermination(10, TimeUnit.SECONDS)); + if (constructorTimeLock != null) { + failure = runCleanup(failure, constructorTimeLock::unlockTimeSkipping); + } + failure = runCleanup(failure, workflowServiceStubs::shutdownNow); + if (testServiceStubs != null) { + failure = runCleanup(failure, () -> testServiceStubs.awaitTermination(1, TimeUnit.SECONDS)); + } + failure = + runCleanup(failure, () -> operatorServiceStubs.awaitTermination(1, TimeUnit.SECONDS)); + failure = + runCleanup(failure, () -> workflowServiceStubs.awaitTermination(1, TimeUnit.SECONDS)); + if (inProcessServer != null) { + failure = runCleanup(failure, inProcessServer::close); + } + } finally { + if (ownedDevServer != null) { + failure = runCleanup(failure, ownedDevServer::close); + } } - workflowServiceStubs.shutdownNow(); - if (testServiceStubs != null) { - testServiceStubs.awaitTermination(1, TimeUnit.SECONDS); + if (failure != null) { + throw failure; } - operatorServiceStubs.awaitTermination(1, TimeUnit.SECONDS); - workflowServiceStubs.awaitTermination(1, TimeUnit.SECONDS); - if (inProcessServer != null) { - inProcessServer.close(); + } + + private static RuntimeException runCleanup( + @Nullable RuntimeException previousFailure, Runnable cleanup) { + try { + cleanup.run(); + } catch (RuntimeException failure) { + if (previousFailure == null) { + return failure; + } + previousFailure.addSuppressed(failure); } + return previousFailure; } @Override diff --git a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowExtension.java b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowExtension.java index c508c72803..b77849d702 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowExtension.java +++ b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowExtension.java @@ -10,6 +10,7 @@ import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowClientOptions; import io.temporal.client.WorkflowOptions; +import io.temporal.common.Experimental; import io.temporal.common.metadata.POJOWorkflowImplMetadata; import io.temporal.common.metadata.POJOWorkflowInterfaceMetadata; import io.temporal.serviceclient.WorkflowServiceStubsOptions; @@ -67,6 +68,12 @@ public class TestWorkflowExtension implements ParameterResolver, TestWatcher, BeforeEachCallback, AfterEachCallback { + private enum ServiceType { + IN_MEMORY, + EXTERNAL, + DEV_SERVER + } + private static final String TEST_ENVIRONMENT_KEY = "testEnvironment"; private static final String WORKER_KEY = "worker"; private static final String WORKFLOW_OPTIONS_KEY = "workflowOptions"; @@ -79,7 +86,8 @@ public class TestWorkflowExtension private final Map, WorkflowImplementationOptions> workflowTypes; private final Object[] activityImplementations; private final Object[] nexusServiceImplementations; - private final boolean useExternalService; + private final ServiceType serviceType; + private final TemporalDevServerOptions devServerOptions; private final String target; private final boolean doNotStart; private final boolean doNotSetupNexusEndpoint; @@ -104,7 +112,8 @@ private TestWorkflowExtension(Builder builder) { workflowTypes = builder.workflowTypes; activityImplementations = builder.activityImplementations; nexusServiceImplementations = builder.nexusServiceImplementations; - useExternalService = builder.useExternalService; + serviceType = builder.serviceType; + devServerOptions = builder.devServerOptions; target = builder.target; doNotStart = builder.doNotStart; doNotSetupNexusEndpoint = builder.doNotSetupNexusEndpoint; @@ -198,35 +207,48 @@ public void beforeEach(ExtensionContext context) { .map(annotation -> Instant.parse(annotation.value()).toEpochMilli()) .orElse(initialTimeMillis); + TestEnvironmentOptions testEnvironmentOptions = createTestEnvOptions(currentInitialTimeMillis); TestWorkflowEnvironment testEnvironment = - TestWorkflowEnvironment.newInstance(createTestEnvOptions(currentInitialTimeMillis)); - - String taskQueue = - String.format("WorkflowTest-%s-%s", context.getDisplayName(), context.getUniqueId()); - String nexusEndpointName = String.format("WorkflowTestNexusEndpoint-%s", UUID.randomUUID()); - boolean createNexusEndpoint = - !doNotSetupNexusEndpoint && nexusServiceImplementations.length > 0; - Worker worker = testEnvironment.newWorker(taskQueue, workerOptions); - workflowTypes.forEach( - (wft, o) -> { - if (createNexusEndpoint) { - o = applyNexusServiceOptions(o, nexusServiceImplementations, nexusEndpointName); - } - worker.registerWorkflowImplementationTypes(o, wft); - }); - worker.registerActivitiesImplementations(activityImplementations); - worker.registerNexusServiceImplementation(nexusServiceImplementations); - - if (!doNotStart) { - testEnvironment.start(); - } - if (createNexusEndpoint) { - setNexusEndpoint(context, testEnvironment.createNexusEndpoint(nexusEndpointName, taskQueue)); - } - - setTestEnvironment(context, testEnvironment); - setWorker(context, worker); - setWorkflowOptions(context, WorkflowOptions.newBuilder().setTaskQueue(taskQueue).build()); + serviceType == ServiceType.DEV_SERVER + ? TestWorkflowEnvironment.startLocal(testEnvironmentOptions, devServerOptions) + : TestWorkflowEnvironment.newInstance(testEnvironmentOptions); + + try { + String taskQueue = + String.format("WorkflowTest-%s-%s", context.getDisplayName(), context.getUniqueId()); + String nexusEndpointName = String.format("WorkflowTestNexusEndpoint-%s", UUID.randomUUID()); + boolean createNexusEndpoint = + !doNotSetupNexusEndpoint && nexusServiceImplementations.length > 0; + Worker worker = testEnvironment.newWorker(taskQueue, workerOptions); + workflowTypes.forEach( + (wft, o) -> { + if (createNexusEndpoint) { + o = applyNexusServiceOptions(o, nexusServiceImplementations, nexusEndpointName); + } + worker.registerWorkflowImplementationTypes(o, wft); + }); + worker.registerActivitiesImplementations(activityImplementations); + worker.registerNexusServiceImplementation(nexusServiceImplementations); + + if (!doNotStart) { + testEnvironment.start(); + } + if (createNexusEndpoint) { + setNexusEndpoint( + context, testEnvironment.createNexusEndpoint(nexusEndpointName, taskQueue)); + } + + setTestEnvironment(context, testEnvironment); + setWorker(context, worker); + setWorkflowOptions(context, WorkflowOptions.newBuilder().setTaskQueue(taskQueue).build()); + } catch (RuntimeException | Error failure) { + try { + testEnvironment.close(); + } catch (RuntimeException | Error cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + throw failure; + } } protected TestEnvironmentOptions createTestEnvOptions(long initialTimeMillis) { @@ -234,7 +256,7 @@ protected TestEnvironmentOptions createTestEnvOptions(long initialTimeMillis) { .setWorkflowClientOptions(workflowClientOptions) .setActivityClientOptions(activityClientOptions) .setWorkerFactoryOptions(workerFactoryOptions) - .setUseExternalService(useExternalService) + .setUseExternalService(serviceType == ServiceType.EXTERNAL) .setUseTimeskipping(useTimeskipping) .setTarget(target) .setInitialTimeMillis(initialTimeMillis) @@ -255,8 +277,10 @@ public void afterEach(ExtensionContext context) { @Override public void testFailed(ExtensionContext context, Throwable cause) { - TestWorkflowEnvironment testEnvironment = getTestEnvironment(context); - System.err.println("Workflow execution histories:\n" + testEnvironment.getDiagnostics()); + if (serviceType == ServiceType.IN_MEMORY) { + TestWorkflowEnvironment testEnvironment = getTestEnvironment(context); + System.err.println("Workflow execution histories:\n" + testEnvironment.getDiagnostics()); + } } private TestWorkflowEnvironment getTestEnvironment(ExtensionContext context) { @@ -311,7 +335,9 @@ public static class Builder { private Map, WorkflowImplementationOptions> workflowTypes = new HashMap<>(); private Object[] activityImplementations = NO_ACTIVITIES; private Object[] nexusServiceImplementations = NO_NEXUS_SERVICES; - private boolean useExternalService = false; + private ServiceType serviceType = ServiceType.IN_MEMORY; + private TemporalDevServerOptions devServerOptions = + TemporalDevServerOptions.getDefaultInstance(); private String target = null; private boolean doNotStart = false; private boolean doNotSetupNexusEndpoint = false; @@ -449,14 +475,46 @@ public Builder useExternalService() { * @see WorkflowServiceStubsOptions.Builder#setTarget(String) */ public Builder useExternalService(String target) { - this.useExternalService = true; + this.serviceType = ServiceType.EXTERNAL; this.target = target; return this; } + /** + * Uses an owned local Temporal dev server instead of the in-memory or external service. + * + *

The extension closes the server after each test. Dev-server tests do not support time + * skipping. + */ + @Experimental + public Builder useDevServer() { + return useDevServer(TemporalDevServerOptions.getDefaultInstance()); + } + + /** + * Uses an owned local Temporal dev server with the supplied options. + * + *

{@code
+     * TestWorkflowExtension.newBuilder()
+     *     .useDevServer(TemporalDevServerOptions.newBuilder().setUiEnabled(true).build())
+     *     .setWorkflowTypes(MyWorkflowImpl.class)
+     *     .build();
+     * }
+ */ + @Experimental + public Builder useDevServer(TemporalDevServerOptions options) { + if (options == null) { + throw new NullPointerException("options"); + } + this.serviceType = ServiceType.DEV_SERVER; + this.target = null; + this.devServerOptions = options; + return this; + } + /** Switches to internal in-memory Temporal service implementation (default). */ public Builder useInternalService() { - this.useExternalService = false; + this.serviceType = ServiceType.IN_MEMORY; this.target = null; return this; } diff --git a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowRule.java b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowRule.java index c5dbce712a..91d20e855a 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowRule.java +++ b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowRule.java @@ -14,6 +14,7 @@ import io.temporal.client.WorkflowClientOptions; import io.temporal.client.WorkflowOptions; import io.temporal.client.WorkflowStub; +import io.temporal.common.Experimental; import io.temporal.common.SearchAttributeKey; import io.temporal.common.interceptors.WorkerInterceptor; import io.temporal.internal.common.env.DebugModeUtils; @@ -65,6 +66,7 @@ public class TestWorkflowRule implements TestRule { private final String namespace; private final boolean useExternalService; + private final boolean useDevServer; private final boolean doNotStart; private final boolean doNotSetupNexusEndpoint; @Nullable private final Timeout globalTimeout; @@ -93,7 +95,10 @@ public class TestWorkflowRule implements TestRule { new TestWatcher() { @Override protected void failed(Throwable e, Description description) { - System.err.println("WORKFLOW EXECUTION HISTORIES:\n" + testEnvironment.getDiagnostics()); + if (!useExternalService && !useDevServer) { + System.err.println( + "WORKFLOW EXECUTION HISTORIES:\n" + testEnvironment.getDiagnostics()); + } } }; @@ -101,6 +106,7 @@ private TestWorkflowRule(Builder builder) { this.doNotStart = builder.doNotStart; this.doNotSetupNexusEndpoint = builder.doNotSetupNexusEndpoint; this.useExternalService = builder.useExternalService; + this.useDevServer = builder.useDevServer; this.namespace = (builder.namespace == null) ? RegisterTestNamespace.NAMESPACE : builder.namespace; this.workflowTypes = (builder.workflowTypes == null) ? new Class[0] : builder.workflowTypes; @@ -141,8 +147,11 @@ private TestWorkflowRule(Builder builder) { this.metricsScope = builder.metricsScope; this.searchAttributes = builder.searchAttributes; + TestEnvironmentOptions testEnvironmentOptions = createTestEnvOptions(builder.initialTimeMillis); this.testEnvironment = - TestWorkflowEnvironment.newInstance(createTestEnvOptions(builder.initialTimeMillis)); + useDevServer + ? TestWorkflowEnvironment.startLocal(testEnvironmentOptions, builder.devServerOptions) + : TestWorkflowEnvironment.newInstance(testEnvironmentOptions); } protected TestEnvironmentOptions createTestEnvOptions(long initialTimeMillis) { @@ -169,6 +178,9 @@ public static class Builder { private String namespace; private String target; private boolean useExternalService; + private boolean useDevServer; + private TemporalDevServerOptions devServerOptions = + TemporalDevServerOptions.getDefaultInstance(); private boolean doNotStart; private boolean doNotSetupNexusEndpoint; private long initialTimeMillis; @@ -266,6 +278,41 @@ public Builder setActivityImplementations(Object... activityImplementations) { */ public Builder setUseExternalService(boolean useExternalService) { this.useExternalService = useExternalService; + this.useDevServer = false; + return this; + } + + /** + * Uses an owned local Temporal dev server instead of the in-memory or external service. + * + *

The rule closes the server during normal teardown. Dev-server tests do not support time + * skipping. + */ + @Experimental + public Builder useDevServer() { + return useDevServer(TemporalDevServerOptions.getDefaultInstance()); + } + + /** + * Uses an owned local Temporal dev server with the supplied options. + * + *

{@code
+     * TestWorkflowRule.newBuilder()
+     *     .useDevServer(
+     *         TemporalDevServerOptions.newBuilder().setDownloadVersion("1.7.2").build())
+     *     .setWorkflowTypes(MyWorkflowImpl.class)
+     *     .build();
+     * }
+ */ + @Experimental + public Builder useDevServer(TemporalDevServerOptions options) { + if (options == null) { + throw new NullPointerException("options"); + } + this.useDevServer = true; + this.useExternalService = false; + this.target = null; + this.devServerOptions = options; return this; } @@ -403,9 +450,12 @@ public Statement apply(Statement base, Description description) { new Statement() { @Override public void evaluate() throws Throwable { - start(); - base.evaluate(); - shutdown(); + try { + start(); + base.evaluate(); + } finally { + shutdown(); + } } }; diff --git a/temporal-testing/src/main/java/io/temporal/testing/internal/DevServerTestPreparation.java b/temporal-testing/src/main/java/io/temporal/testing/internal/DevServerTestPreparation.java new file mode 100644 index 0000000000..63706143f5 --- /dev/null +++ b/temporal-testing/src/main/java/io/temporal/testing/internal/DevServerTestPreparation.java @@ -0,0 +1,13 @@ +package io.temporal.testing.internal; + +import io.temporal.testing.internal.devserver.SdkJavaTestServerProfile; + +/** Download-only entry point used by sdk-java's {@code prepareDevServerTests} task. */ +public final class DevServerTestPreparation { + private DevServerTestPreparation() {} + + public static void main(String[] args) { + System.out.println( + "Prepared Temporal CLI at " + SdkJavaTestServerProfile.prepare().toAbsolutePath()); + } +} diff --git a/temporal-testing/src/main/java/io/temporal/testing/internal/ExternalServiceTestConfigurator.java b/temporal-testing/src/main/java/io/temporal/testing/internal/ExternalServiceTestConfigurator.java index b81a9d363c..0f5f9d4605 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/internal/ExternalServiceTestConfigurator.java +++ b/temporal-testing/src/main/java/io/temporal/testing/internal/ExternalServiceTestConfigurator.java @@ -3,6 +3,7 @@ import io.temporal.internal.common.env.EnvironmentVariableUtils; import io.temporal.testing.TestEnvironmentOptions; import io.temporal.testing.TestWorkflowRule; +import io.temporal.testing.internal.devserver.SdkJavaTestServerProfile; public class ExternalServiceTestConfigurator { private static boolean USE_EXTERNAL_SERVICE = @@ -13,7 +14,7 @@ public class ExternalServiceTestConfigurator { EnvironmentVariableUtils.readBooleanFlag("USE_VIRTUAL_THREADS"); public static boolean isUseExternalService() { - return USE_EXTERNAL_SERVICE; + return USE_EXTERNAL_SERVICE || SdkJavaTestServerProfile.isActive(); } public static boolean isUseVirtualThreads() { @@ -21,16 +22,20 @@ public static boolean isUseVirtualThreads() { } public static String getTemporalServiceAddress() { + if (SdkJavaTestServerProfile.isActive()) { + return SdkJavaTestServerProfile.getTarget(); + } return USE_EXTERNAL_SERVICE ? (TEMPORAL_SERVICE_ADDRESS != null ? TEMPORAL_SERVICE_ADDRESS : "127.0.0.1:7233") : null; } public static TestWorkflowRule.Builder configure(TestWorkflowRule.Builder testWorkflowRule) { - if (USE_EXTERNAL_SERVICE) { + if (isUseExternalService()) { testWorkflowRule.setUseExternalService(true); - if (TEMPORAL_SERVICE_ADDRESS != null) { - testWorkflowRule.setTarget(TEMPORAL_SERVICE_ADDRESS); + String target = getTemporalServiceAddress(); + if (target != null) { + testWorkflowRule.setTarget(target); } } return testWorkflowRule; @@ -38,10 +43,11 @@ public static TestWorkflowRule.Builder configure(TestWorkflowRule.Builder testWo public static TestEnvironmentOptions.Builder configure( TestEnvironmentOptions.Builder testEnvironmentOptions) { - if (USE_EXTERNAL_SERVICE) { + if (isUseExternalService()) { testEnvironmentOptions.setUseExternalService(true); - if (TEMPORAL_SERVICE_ADDRESS != null) { - testEnvironmentOptions.setTarget(TEMPORAL_SERVICE_ADDRESS); + String target = getTemporalServiceAddress(); + if (target != null) { + testEnvironmentOptions.setTarget(target); } } return testEnvironmentOptions; diff --git a/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java b/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java new file mode 100644 index 0000000000..1da0898b33 --- /dev/null +++ b/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java @@ -0,0 +1,171 @@ +package io.temporal.testing.internal.devserver; + +import io.temporal.testing.TemporalDevServer; +import io.temporal.testing.TemporalDevServerOptions; +import java.io.File; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; + +/** JVM-wide dev server used only by sdk-java's Gradle test profile. */ +public final class SdkJavaTestServerProfile { + public static final String ACTIVE_PROPERTY = "io.temporal.testing.internal.devServerProfile"; + public static final String DOWNLOAD_DESTINATION_PROPERTY = + "io.temporal.testing.internal.devServerDownloadDestination"; + public static final String DOWNLOAD_ENABLED_PROPERTY = + "io.temporal.testing.internal.devServerDownloadEnabled"; + public static final String WORKING_DIRECTORY_PROPERTY = + "io.temporal.testing.internal.devServerWorkingDirectory"; + + // This is intentionally the sole Temporal CLI version used by sdk-java repository tests. + private static final String TEST_CLI_VERSION = "1.7.2-standalone-nexus-operations"; + private static final String TEST_NAMESPACE = "UnitTest"; + private static final String DATABASE_FILENAME = "temporal.sqlite"; + + private static TemporalDevServer server; + private static boolean shutdownHookRegistered; + + private SdkJavaTestServerProfile() {} + + public static boolean isActive() { + return Boolean.parseBoolean(System.getProperty(ACTIVE_PROPERTY, "false")); + } + + public static synchronized String getTarget() { + if (!isActive()) { + return null; + } + if (server == null) { + File workingDirectory = workingDirectory(); + cleanDatabase(workingDirectory); + try { + server = TemporalDevServer.start(TEST_NAMESPACE, serverOptions(workingDirectory)); + } catch (RuntimeException | Error failure) { + cleanDatabase(workingDirectory); + throw failure; + } + if (!shutdownHookRegistered) { + Runtime.getRuntime() + .addShutdownHook( + new Thread(SdkJavaTestServerProfile::shutdown, "sdk-java-dev-server-shutdown")); + shutdownHookRegistered = true; + } + } + return server.getTarget(); + } + + public static Path prepare() { + return TemporalDevServerDownloader.prepare(downloadOptions()); + } + + public static synchronized void shutdown() { + if (server != null) { + server.close(); + server = null; + } + cleanDatabase(workingDirectory()); + } + + private static TemporalDevServerOptions serverOptions(File workingDirectory) { + return TemporalDevServerOptions.newBuilder(downloadOptions()) + .setIp("127.0.0.1") + .setPort(7233) + .setUiEnabled(false) + .setDatabaseFilename(DATABASE_FILENAME) + .setWorkingDirectory(workingDirectory.getAbsolutePath()) + .setLogFile(new File(workingDirectory, "server.log").getAbsolutePath()) + .setExtraArgs(repositoryServerArguments()) + .build(); + } + + private static TemporalDevServerOptions downloadOptions() { + TemporalDevServerOptions.Builder builder = + TemporalDevServerOptions.newBuilder() + .setDownloadVersion("v" + TEST_CLI_VERSION) + .setDownloadEnabled( + Boolean.parseBoolean(System.getProperty(DOWNLOAD_ENABLED_PROPERTY, "true"))); + String destination = System.getProperty(DOWNLOAD_DESTINATION_PROPERTY); + if (destination != null) { + builder.setDownloadDestination(destination); + } + return builder.build(); + } + + private static List repositoryServerArguments() { + return Arrays.asList( + "--http-port", + "7243", + "--sqlite-pragma", + "journal_mode=WAL", + "--sqlite-pragma", + "synchronous=OFF", + "--search-attribute", + "CustomKeywordField=Keyword", + "--search-attribute", + "CustomStringField=Text", + "--search-attribute", + "CustomTextField=Text", + "--search-attribute", + "CustomIntField=Int", + "--search-attribute", + "CustomDatetimeField=Datetime", + "--search-attribute", + "CustomDoubleField=Double", + "--search-attribute", + "CustomBoolField=Bool", + "--dynamic-config-value", + "system.enableActivityEagerExecution=true", + "--dynamic-config-value", + "history.MaxBufferedQueryCount=10000", + "--dynamic-config-value", + "frontend.workerVersioningDataAPIs=true", + "--dynamic-config-value", + "history.enableRequestIdRefLinks=true", + "--dynamic-config-value", + "frontend.WorkerHeartbeatsEnabled=true", + "--dynamic-config-value", + "frontend.ListWorkersEnabled=true", + "--dynamic-config-value", + "frontend.enableCancelWorkerPollsOnShutdown=true", + "--dynamic-config-value", + "component.callbacks.allowedAddresses=[{\"Pattern\":\"localhost:7243\",\"AllowInsecure\":true}]", + "--dynamic-config-value", + "callback.allowedAddresses=[{\"Pattern\":\"localhost:7243\",\"AllowInsecure\":true}]", + "--dynamic-config-value", + "frontend.activityAPIsEnabled=true", + "--dynamic-config-value", + "activity.enableStandalone=true", + "--dynamic-config-value", + "activity.startDelayEnabled=true", + "--dynamic-config-value", + "nexusoperation.enableStandalone=true", + "--dynamic-config-value", + "history.enableChasm=true", + "--dynamic-config-value", + "history.enableCHASMSignalBacklinks=true", + "--dynamic-config-value", + "history.enableTransitionHistory=true", + "--dynamic-config-value", + "frontend.enableCancelWorkerPollsOnShutdown=true", + "--dynamic-config-value", + "frontend.workerCommandsEnabled=true", + "--dynamic-config-value", + "system.enableCancelActivityWorkerCommand=true"); + } + + private static File workingDirectory() { + return new File( + System.getProperty( + WORKING_DIRECTORY_PROPERTY, new File("build", "temporal-cli/server").getPath())); + } + + private static void cleanDatabase(File workingDirectory) { + for (String name : + Arrays.asList(DATABASE_FILENAME, DATABASE_FILENAME + "-shm", DATABASE_FILENAME + "-wal")) { + File file = new File(workingDirectory, name); + if (file.exists() && !file.delete()) { + System.err.println("Unable to delete Temporal dev-server database file " + file); + } + } + } +} diff --git a/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/TemporalDevServerDownloader.java b/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/TemporalDevServerDownloader.java new file mode 100644 index 0000000000..6833fa249a --- /dev/null +++ b/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/TemporalDevServerDownloader.java @@ -0,0 +1,360 @@ +package io.temporal.testing.internal.devserver; + +import com.google.gson.Gson; +import io.temporal.serviceclient.Version; +import io.temporal.testing.TemporalDevServerOptions; +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Duration; +import java.util.Locale; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.zip.GZIPInputStream; +import org.apache.commons.compress.archivers.ArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; +import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream; + +/** Internal downloader and executable cache for the Temporal CLI. */ +public final class TemporalDevServerDownloader { + private static final String DOWNLOAD_BASE_URL_PROPERTY = + "io.temporal.testing.devServerDownloadBaseUrl"; + private static final String DEFAULT_DOWNLOAD_BASE_URL = "https://temporal.download"; + // FileLock coordinates separate JVMs; this monitor prevents overlapping locks within one JVM. + private static final ConcurrentMap JVM_LOCKS = new ConcurrentHashMap<>(); + + private TemporalDevServerDownloader() {} + + public static Path prepare(TemporalDevServerOptions options) { + String existingPath = options.getExistingPath(); + if (existingPath != null) { + Path executable = new File(existingPath).toPath().toAbsolutePath().normalize(); + if (!Files.isRegularFile(executable)) { + throw new IllegalStateException( + "Temporal CLI executable does not exist or is not a file: " + executable); + } + if (!isWindows() && !Files.isExecutable(executable)) { + throw new IllegalStateException("Temporal CLI executable is not executable: " + executable); + } + return executable; + } + + Platform platform = Platform.current(); + Path cacheDirectory = cacheDirectory(options, platform); + Path executable = cacheDirectory.resolve(platform.executableName); + Object jvmLock = + JVM_LOCKS.computeIfAbsent( + cacheDirectory.toAbsolutePath().normalize().toString(), ignored -> new Object()); + synchronized (jvmLock) { + try { + Files.createDirectories(cacheDirectory); + Path lockPath = cacheDirectory.resolve(".download.lock"); + try (FileChannel lockChannel = + FileChannel.open( + lockPath, + java.nio.file.StandardOpenOption.CREATE, + java.nio.file.StandardOpenOption.WRITE); + FileLock ignored = lockChannel.lock()) { + if (isUsableCacheEntry(executable, options.getDownloadCacheTtl())) { + return executable; + } + if (!options.isDownloadEnabled()) { + throw new IllegalStateException( + "Temporal CLI " + + options.getDownloadVersion() + + " for " + + platform.classifier() + + " is not cached at " + + executable + + " and downloading is disabled."); + } + Files.deleteIfExists(executable); + DownloadInfo info = getDownloadInfo(options, platform); + downloadAndExtract(info, executable, cacheDirectory); + return executable; + } + } catch (IOException e) { + throw new IllegalStateException( + "Failed preparing Temporal CLI " + options.getDownloadVersion(), e); + } + } + } + + static Path cacheDirectory(TemporalDevServerOptions options, Platform platform) { + String destination = options.getDownloadDestination(); + Path root = + destination == null + ? new File(System.getProperty("java.io.tmpdir"), "temporal-dev-server").toPath() + : new File(destination).toPath(); + String version = + "default".equals(options.getDownloadVersion()) + ? "default-sdk-java-" + safePathPart(Version.LIBRARY_VERSION) + : safePathPart(options.getDownloadVersion()); + return root.toAbsolutePath().normalize().resolve(version).resolve(platform.classifier()); + } + + private static boolean isUsableCacheEntry(Path executable, Duration ttl) throws IOException { + if (!Files.isRegularFile(executable)) { + return false; + } + if (!isWindows() && !Files.isExecutable(executable)) { + return false; + } + if (ttl == null) { + return true; + } + long ageMillis = + Math.max(0, System.currentTimeMillis() - Files.getLastModifiedTime(executable).toMillis()); + return ageMillis <= ttl.toMillis(); + } + + private static DownloadInfo getDownloadInfo(TemporalDevServerOptions options, Platform platform) + throws IOException { + String version = encodeQueryValue(options.getDownloadVersion()).replace("+", "%20"); + StringBuilder url = + new StringBuilder( + System.getProperty(DOWNLOAD_BASE_URL_PROPERTY, DEFAULT_DOWNLOAD_BASE_URL) + + "/cli/" + + version + + "?platform=" + + encodeQueryValue(platform.platform) + + "&arch=" + + encodeQueryValue(platform.architecture) + + "&format=tar.gz"); + if ("default".equals(options.getDownloadVersion())) { + url.append("&sdk-name=sdk-java"); + url.append("&sdk-version=").append(encodeQueryValue(Version.LIBRARY_VERSION)); + } + HttpURLConnection connection = openFollowingRedirects(url.toString()); + try { + int status = connection.getResponseCode(); + if (status < 200 || status >= 300) { + throw new IOException( + "temporal.download returned HTTP " + status + " for " + connection.getURL()); + } + try (InputStreamReader reader = + new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8)) { + DownloadInfo info = new Gson().fromJson(reader, DownloadInfo.class); + if (info == null || isBlank(info.archiveUrl) || isBlank(info.fileToExtract)) { + throw new IOException("temporal.download returned incomplete download metadata"); + } + return info; + } + } finally { + connection.disconnect(); + } + } + + private static void downloadAndExtract(DownloadInfo info, Path executable, Path cacheDirectory) + throws IOException { + Path archive = + cacheDirectory.resolve("archive-" + UUID.randomUUID().toString() + ".downloading"); + Path extracted = + cacheDirectory.resolve(executable.getFileName() + "." + UUID.randomUUID() + ".extracting"); + try { + HttpURLConnection connection = openFollowingRedirects(info.archiveUrl); + try { + int status = connection.getResponseCode(); + if (status < 200 || status >= 300) { + throw new IOException( + "CLI archive download returned HTTP " + status + " for " + connection.getURL()); + } + try (InputStream input = new BufferedInputStream(connection.getInputStream()); + OutputStream output = + new BufferedOutputStream(new FileOutputStream(archive.toFile()))) { + copy(input, output); + } + } finally { + connection.disconnect(); + } + + extractRequestedFile(archive, info.fileToExtract, extracted); + if (!isWindows() && !extracted.toFile().setExecutable(true, true)) { + throw new IOException("Unable to make Temporal CLI executable: " + extracted); + } + atomicMove(extracted, executable); + } finally { + Files.deleteIfExists(archive); + Files.deleteIfExists(extracted); + } + } + + static void extractRequestedFile(Path archive, String requestedName, Path destination) + throws IOException { + try (BufferedInputStream input = + new BufferedInputStream(new FileInputStream(archive.toFile()))) { + input.mark(4); + int first = input.read(); + int second = input.read(); + input.reset(); + if (first == 'P' && second == 'K') { + try (ZipArchiveInputStream zip = new ZipArchiveInputStream(input)) { + extractEntry(zip, requestedName, destination); + } + } else { + try (TarArchiveInputStream tar = new TarArchiveInputStream(new GZIPInputStream(input))) { + extractEntry(tar, requestedName, destination); + } + } + } + } + + private static void extractEntry( + org.apache.commons.compress.archivers.ArchiveInputStream archive, + String requestedName, + Path destination) + throws IOException { + String normalizedRequested = normalizeArchiveName(requestedName); + ArchiveEntry entry; + while ((entry = archive.getNextEntry()) != null) { + if (!entry.isDirectory() + && normalizeArchiveName(entry.getName()).equals(normalizedRequested)) { + try (OutputStream output = + new BufferedOutputStream(new FileOutputStream(destination.toFile()))) { + copy(archive, output); + } + return; + } + } + throw new IOException("CLI archive did not contain " + requestedName); + } + + private static String normalizeArchiveName(String name) { + String normalized = name.replace('\\', '/'); + while (normalized.startsWith("./")) { + normalized = normalized.substring(2); + } + return normalized; + } + + private static HttpURLConnection openFollowingRedirects(String url) throws IOException { + String next = url; + for (int redirects = 0; redirects <= 5; redirects++) { + HttpURLConnection connection = (HttpURLConnection) URI.create(next).toURL().openConnection(); + connection.setConnectTimeout(15_000); + connection.setReadTimeout(60_000); + connection.setRequestProperty("Accept", "application/json, application/octet-stream"); + connection.setRequestProperty("User-Agent", "temporal-sdk-java/" + Version.LIBRARY_VERSION); + connection.setInstanceFollowRedirects(false); + int status = connection.getResponseCode(); + if (status != HttpURLConnection.HTTP_MOVED_PERM + && status != HttpURLConnection.HTTP_MOVED_TEMP + && status != HttpURLConnection.HTTP_SEE_OTHER + && status != 307 + && status != 308) { + return connection; + } + String location = connection.getHeaderField("Location"); + if (location == null) { + return connection; + } + next = URI.create(next).resolve(location).toString(); + connection.disconnect(); + } + throw new IOException("Too many redirects downloading " + url); + } + + private static void atomicMove(Path source, Path destination) throws IOException { + try { + Files.move( + source, destination, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + Files.move(source, destination, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static void copy(InputStream input, OutputStream output) throws IOException { + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + } + + private static String encodeQueryValue(String value) { + try { + return URLEncoder.encode(value, "UTF-8"); + } catch (java.io.UnsupportedEncodingException e) { + throw new AssertionError(e); + } + } + + private static String safePathPart(String value) { + return value.replaceAll("[^A-Za-z0-9._-]", "_"); + } + + private static boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } + + private static boolean isWindows() { + return System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("windows"); + } + + private static final class DownloadInfo { + private String archiveUrl; + private String fileToExtract; + } + + static final class Platform { + private final String platform; + private final String architecture; + private final String executableName; + + private Platform(String platform, String architecture, String executableName) { + this.platform = platform; + this.architecture = architecture; + this.executableName = executableName; + } + + static Platform current() { + String os = System.getProperty("os.name").toLowerCase(Locale.ROOT); + String platform; + String executableName; + if (os.contains("mac") || os.contains("darwin")) { + platform = "darwin"; + executableName = "temporal"; + } else if (os.contains("windows")) { + platform = "windows"; + executableName = "temporal.exe"; + } else if (os.contains("linux")) { + platform = "linux"; + executableName = "temporal"; + } else { + throw new IllegalStateException("Unsupported operating system: " + os); + } + + String machine = System.getProperty("os.arch").toLowerCase(Locale.ROOT); + String architecture; + if (machine.equals("x86_64") || machine.equals("amd64")) { + architecture = "amd64"; + } else if (machine.equals("aarch64") || machine.equals("arm64")) { + architecture = "arm64"; + } else { + throw new IllegalStateException("Unsupported architecture: " + machine); + } + return new Platform(platform, architecture, executableName); + } + + String classifier() { + return platform + "_" + architecture; + } + } +} diff --git a/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/TemporalDevServerLauncher.java b/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/TemporalDevServerLauncher.java new file mode 100644 index 0000000000..c47c2da080 --- /dev/null +++ b/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/TemporalDevServerLauncher.java @@ -0,0 +1,295 @@ +package io.temporal.testing.internal.devserver; + +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import io.grpc.StatusRuntimeException; +import io.grpc.health.v1.HealthCheckRequest; +import io.grpc.health.v1.HealthCheckResponse; +import io.grpc.health.v1.HealthGrpc; +import io.temporal.api.workflowservice.v1.DescribeNamespaceRequest; +import io.temporal.api.workflowservice.v1.WorkflowServiceGrpc; +import io.temporal.testing.TemporalDevServerOptions; +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Internal ProcessBuilder-based Temporal dev-server launcher. */ +public final class TemporalDevServerLauncher { + private static final int LOG_TAIL_LINES = 200; + private static final long GRACEFUL_SHUTDOWN_SECONDS = 10; + + private TemporalDevServerLauncher() {} + + public static RunningServer start(String namespace, TemporalDevServerOptions options) { + Path executable = TemporalDevServerDownloader.prepare(options); + int port = options.getPort() == null ? reservePort(options.getIp()) : options.getPort(); + String target = targetHost(options.getIp()) + ":" + port; + List command = buildCommand(executable, namespace, options, port); + + Process process = null; + File logFile = options.getLogFile() == null ? null : new File(options.getLogFile()); + try { + ProcessBuilder processBuilder = new ProcessBuilder(command).redirectErrorStream(true); + if (options.getWorkingDirectory() != null) { + File workingDirectory = new File(options.getWorkingDirectory()); + if (!workingDirectory.isDirectory() && !workingDirectory.mkdirs()) { + throw new IOException("Unable to create working directory " + workingDirectory); + } + processBuilder.directory(workingDirectory); + } + configureOutput(processBuilder, logFile); + process = processBuilder.start(); + waitUntilReady(process, target, namespace, options, command, logFile); + return new RunningServer(target, process); + } catch (Throwable failure) { + stopProcess(process); + if (failure instanceof Error) { + throw (Error) failure; + } + if (failure instanceof IllegalStateException) { + throw (IllegalStateException) failure; + } + throw startupFailure("Unable to start Temporal dev server", command, logFile, failure); + } + } + + private static void configureOutput(ProcessBuilder processBuilder, File logFile) + throws IOException { + if (logFile == null) { + processBuilder.redirectOutput(ProcessBuilder.Redirect.INHERIT); + return; + } + File parent = logFile.getAbsoluteFile().getParentFile(); + if (parent != null && !parent.isDirectory() && !parent.mkdirs()) { + throw new IOException("Unable to create log directory " + parent); + } + processBuilder.redirectOutput(ProcessBuilder.Redirect.to(logFile)); + } + + static List buildCommand( + Path executable, String namespace, TemporalDevServerOptions options, int port) { + List command = new ArrayList<>(); + command.add(executable.toAbsolutePath().toString()); + command.add("server"); + command.add("start-dev"); + command.add("--port"); + command.add(Integer.toString(port)); + command.add("--namespace"); + command.add(namespace); + command.add("--ip"); + command.add(options.getIp()); + command.add("--log-format"); + command.add(options.getLogFormat()); + command.add("--log-level"); + command.add(options.getLogLevel()); + // Keep these defaults in sync with sdk-core's TemporalDevServerConfig. + command.add("--dynamic-config-value"); + command.add("frontend.enableServerVersionCheck=false"); + command.add("--dynamic-config-value"); + command.add("frontend.enableUpdateWorkflowExecution=true"); + command.add("--dynamic-config-value"); + command.add("frontend.enableUpdateWorkflowExecutionAsyncAccepted=true"); + if (options.getDatabaseFilename() != null) { + command.add("--db-filename"); + command.add(options.getDatabaseFilename()); + } + if (options.getUiPort() != null) { + command.add("--ui-port"); + command.add(Integer.toString(options.getUiPort())); + } else if (options.isUiEnabled()) { + command.add("--ui-port"); + command.add(Integer.toString(Math.min(65535, port + 1000))); + } else { + command.add("--headless"); + } + command.addAll(options.getExtraArgs()); + return command; + } + + private static void waitUntilReady( + Process process, + String target, + String namespace, + TemporalDevServerOptions options, + List command, + File logFile) { + long timeoutNanos = options.getStartupTimeout().toNanos(); + long startNanos = System.nanoTime(); + long deadlineNanos = + Long.MAX_VALUE - startNanos < timeoutNanos ? Long.MAX_VALUE : startNanos + timeoutNanos; + ManagedChannel channel = + ManagedChannelBuilder.forTarget(target).usePlaintext().directExecutor().build(); + Throwable lastFailure = null; + try { + while (System.nanoTime() < deadlineNanos) { + if (!process.isAlive()) { + throw startupFailure( + "Temporal dev server exited prematurely with code " + process.exitValue(), + command, + logFile, + lastFailure); + } + long remainingNanos = deadlineNanos - System.nanoTime(); + long rpcNanos = Math.max(1, Math.min(TimeUnit.SECONDS.toNanos(1), remainingNanos)); + try { + HealthCheckResponse health = + HealthGrpc.newBlockingStub(channel) + .withDeadlineAfter(rpcNanos, TimeUnit.NANOSECONDS) + .check( + HealthCheckRequest.newBuilder() + .setService(WorkflowServiceGrpc.SERVICE_NAME) + .build()); + if (health.getStatus() != HealthCheckResponse.ServingStatus.SERVING) { + throw new IllegalStateException("gRPC health service is " + health.getStatus()); + } + WorkflowServiceGrpc.newBlockingStub(channel) + .withDeadlineAfter(rpcNanos, TimeUnit.NANOSECONDS) + .describeNamespace( + DescribeNamespaceRequest.newBuilder().setNamespace(namespace).build()); + return; + } catch (StatusRuntimeException | IllegalStateException e) { + lastFailure = e; + } + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw startupFailure( + "Interrupted while waiting for Temporal dev server", command, logFile, e); + } + } + throw startupFailure( + "Temporal dev server did not become ready within " + options.getStartupTimeout(), + command, + logFile, + lastFailure); + } finally { + channel.shutdownNow(); + try { + channel.awaitTermination(1, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + private static IllegalStateException startupFailure( + String message, List command, File logFile, Throwable cause) { + return new IllegalStateException( + message + + "\nCommand: " + + renderCommand(command) + + "\nTemporal dev-server output tail:\n" + + readOutputTail(logFile), + cause); + } + + private static String readOutputTail(File logFile) { + if (logFile == null) { + return ""; + } + if (!logFile.isFile()) { + return ""; + } + Deque tail = new ArrayDeque<>(); + try (BufferedReader reader = + new BufferedReader( + new InputStreamReader(new FileInputStream(logFile), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + tail.addLast(line); + while (tail.size() > LOG_TAIL_LINES) { + tail.removeFirst(); + } + } + } catch (IOException e) { + return ""; + } + if (tail.isEmpty()) { + return ""; + } + return String.join(System.lineSeparator(), tail); + } + + private static String renderCommand(List command) { + StringBuilder rendered = new StringBuilder(); + for (String part : command) { + if (rendered.length() > 0) { + rendered.append(' '); + } + if (part.indexOf(' ') >= 0) { + rendered.append('"').append(part.replace("\"", "\\\"")).append('"'); + } else { + rendered.append(part); + } + } + return rendered.toString(); + } + + private static int reservePort(String ip) { + try (ServerSocket socket = new ServerSocket(0, 0, InetAddress.getByName(ip))) { + socket.setReuseAddress(true); + return socket.getLocalPort(); + } catch (IOException e) { + throw new IllegalStateException("Unable to reserve a port on " + ip, e); + } + } + + private static String targetHost(String ip) { + if ("0.0.0.0".equals(ip) || "::".equals(ip) || "0:0:0:0:0:0:0:0".equals(ip)) { + return "127.0.0.1"; + } + return ip.indexOf(':') >= 0 ? "[" + ip + "]" : ip; + } + + private static void stopProcess(Process process) { + if (process == null || !process.isAlive()) { + return; + } + process.destroy(); + try { + if (!process.waitFor(GRACEFUL_SHUTDOWN_SECONDS, TimeUnit.SECONDS)) { + process.destroyForcibly(); + process.waitFor(GRACEFUL_SHUTDOWN_SECONDS, TimeUnit.SECONDS); + } + } catch (InterruptedException e) { + process.destroyForcibly(); + Thread.currentThread().interrupt(); + } + } + + public static final class RunningServer implements AutoCloseable { + private final String target; + private final Process process; + private final AtomicBoolean closed = new AtomicBoolean(); + + private RunningServer(String target, Process process) { + this.target = target; + this.process = process; + } + + public String getTarget() { + return target; + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) { + return; + } + stopProcess(process); + } + } +} diff --git a/temporal-testing/src/test/java/io/temporal/testing/TemporalDevServerIntegrationTest.java b/temporal-testing/src/test/java/io/temporal/testing/TemporalDevServerIntegrationTest.java new file mode 100644 index 0000000000..c8266c6bb1 --- /dev/null +++ b/temporal-testing/src/test/java/io/temporal/testing/TemporalDevServerIntegrationTest.java @@ -0,0 +1,208 @@ +package io.temporal.testing; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.temporal.client.WorkflowClientOptions; +import io.temporal.common.interceptors.WorkflowClientCallsInterceptor; +import io.temporal.common.interceptors.WorkflowClientInterceptorBase; +import io.temporal.testing.internal.devserver.SdkJavaTestServerProfile; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.file.Path; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.io.TempDir; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; + +/** Integration coverage for dev-server ownership using sdk-java's pinned real Temporal CLI. */ +@EnabledIfSystemProperty(named = SdkJavaTestServerProfile.ACTIVE_PROPERTY, matches = "true") +class TemporalDevServerIntegrationTest { + private static Path temporalCli; + + @TempDir Path tempDirectory; + + @BeforeAll + static void prepareTemporalCli() { + temporalCli = SdkJavaTestServerProfile.prepare(); + } + + @Test + void standaloneServerBecomesReadyAndCloseIsIdempotent() throws Exception { + TemporalDevServer server = TemporalDevServer.start("MyNamespace", realServerOptions().build()); + String target = server.getTarget(); + + assertEquals("MyNamespace", server.getNamespace()); + assertTrue(canConnect(target)); + + server.close(); + server.close(); + + assertTrue(awaitClosed(target)); + } + + @Test + void prematureExitIncludesCommandAndCliOutput() { + TemporalDevServerOptions options = + realServerOptions().setExtraArgs("--definitely-not-a-real-temporal-cli-argument").build(); + + IllegalStateException failure = + assertThrows( + IllegalStateException.class, () -> TemporalDevServer.start("default", options)); + + assertTrue(failure.getMessage().contains("exited prematurely")); + assertTrue(failure.getMessage().contains("--definitely-not-a-real-temporal-cli-argument")); + assertTrue(failure.getMessage().contains("Temporal dev-server output tail")); + } + + @Test + void startupTimeoutStopsCliProcess() throws Exception { + int port = availablePort(); + TemporalDevServerOptions options = + realServerOptions().setPort(port).setStartupTimeout(Duration.ofNanos(1)).build(); + + IllegalStateException failure = + assertThrows( + IllegalStateException.class, () -> TemporalDevServer.start("default", options)); + + assertTrue(failure.getMessage().contains("did not become ready")); + assertTrue(awaitClosed("127.0.0.1:" + port)); + } + + @Test + void environmentOwnsServerAndUsesEnvironmentNamespace() throws Exception { + String firstTarget; + try (TestWorkflowEnvironment environment = + TestWorkflowEnvironment.startLocal(realServerOptions().build())) { + assertEquals("default", environment.getNamespace()); + firstTarget = environment.getWorkflowServiceStubs().getOptions().getTarget(); + assertTrue(canConnect(firstTarget)); + } + assertTrue(awaitClosed(firstTarget)); + + TestEnvironmentOptions testOptions = + TestEnvironmentOptions.newBuilder() + .setWorkflowClientOptions( + WorkflowClientOptions.newBuilder().setNamespace("Authoritative").build()) + .build(); + String combinedTarget; + try (TestWorkflowEnvironment environment = + TestWorkflowEnvironment.startLocal(testOptions, realServerOptions().build())) { + assertEquals("Authoritative", environment.getNamespace()); + combinedTarget = environment.getWorkflowServiceStubs().getOptions().getTarget(); + assertTrue(canConnect(combinedTarget)); + } + assertTrue(awaitClosed(combinedTarget)); + } + + @Test + void constructionFailureStopsPartiallyStartedServer() throws Exception { + int port = availablePort(); + TestEnvironmentOptions testOptions = + TestEnvironmentOptions.newBuilder() + .setWorkflowClientOptions( + WorkflowClientOptions.newBuilder() + .setInterceptors( + new WorkflowClientInterceptorBase() { + @Override + public WorkflowClientCallsInterceptor workflowClientCallsInterceptor( + WorkflowClientCallsInterceptor next) { + throw new DeliberateTestFailure(); + } + }) + .build()) + .build(); + + assertThrows( + DeliberateTestFailure.class, + () -> + TestWorkflowEnvironment.startLocal( + testOptions, realServerOptions().setPort(port).build())); + + assertTrue(awaitClosed("127.0.0.1:" + port)); + } + + @Test + void junit4RuleClosesServerAfterSuccessAndFailure() throws Throwable { + TestWorkflowRule successfulRule = + TestWorkflowRule.newBuilder() + .useDevServer(realServerOptions().build()) + .setWorkflowTypes() + .build(); + String successTarget = successfulRule.getWorkflowServiceStubs().getOptions().getTarget(); + successfulRule + .apply( + new Statement() { + @Override + public void evaluate() {} + }, + Description.createTestDescription(getClass(), "successfulRule")) + .evaluate(); + assertTrue(awaitClosed(successTarget)); + + TestWorkflowRule failingRule = + TestWorkflowRule.newBuilder() + .useDevServer(realServerOptions().build()) + .setWorkflowTypes() + .build(); + String failureTarget = failingRule.getWorkflowServiceStubs().getOptions().getTarget(); + assertThrows( + DeliberateTestFailure.class, + () -> + failingRule + .apply( + new Statement() { + @Override + public void evaluate() { + throw new DeliberateTestFailure(); + } + }, + Description.createTestDescription(getClass(), "failingRule")) + .evaluate()); + assertTrue(awaitClosed(failureTarget)); + } + + private TemporalDevServerOptions.Builder realServerOptions() { + return TemporalDevServerOptions.newBuilder() + .setExistingPath(temporalCli.toString()) + .setStartupTimeout(Duration.ofSeconds(60)) + .setLogFile(tempDirectory.resolve("server-" + System.nanoTime() + ".log").toString()); + } + + private static int availablePort() throws IOException { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private static boolean awaitClosed(String target) throws InterruptedException { + for (int i = 0; i < 50; i++) { + if (!canConnect(target)) { + return true; + } + TimeUnit.MILLISECONDS.sleep(100); + } + return false; + } + + private static boolean canConnect(String target) { + int separator = target.lastIndexOf(':'); + String host = target.substring(0, separator); + int port = Integer.parseInt(target.substring(separator + 1)); + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(host, port), 250); + return true; + } catch (IOException e) { + return false; + } + } + + private static final class DeliberateTestFailure extends RuntimeException {} +} diff --git a/temporal-testing/src/test/java/io/temporal/testing/TemporalDevServerOptionsTest.java b/temporal-testing/src/test/java/io/temporal/testing/TemporalDevServerOptionsTest.java new file mode 100644 index 0000000000..06bed97cf0 --- /dev/null +++ b/temporal-testing/src/test/java/io/temporal/testing/TemporalDevServerOptionsTest.java @@ -0,0 +1,61 @@ +package io.temporal.testing; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Test; + +class TemporalDevServerOptionsTest { + @Test + void copiesAndDefensivelyCopiesExtraArguments() { + List args = new ArrayList<>(Arrays.asList("--one", "value")); + TemporalDevServerOptions original = + TemporalDevServerOptions.newBuilder() + .setDownloadVersion("arbitrary-fixed-version") + .setExtraArgs(args) + .build(); + args.add("--mutated"); + + TemporalDevServerOptions copy = + TemporalDevServerOptions.newBuilder(original).setUiEnabled(true).build(); + + assertEquals(Arrays.asList("--one", "value"), original.getExtraArgs()); + assertEquals(original.getExtraArgs(), copy.getExtraArgs()); + assertNotSame(original.getExtraArgs(), copy.getExtraArgs()); + assertThrows(UnsupportedOperationException.class, () -> copy.getExtraArgs().add("no")); + assertTrue(copy.isUiEnabled()); + assertFalse(original.isUiEnabled()); + } + + @Test + void validatesValues() { + assertThrows( + IllegalArgumentException.class, + () -> TemporalDevServerOptions.newBuilder().setDownloadVersion(" ").build()); + assertThrows( + IllegalArgumentException.class, + () -> TemporalDevServerOptions.newBuilder().setPort(0).build()); + assertThrows( + IllegalArgumentException.class, + () -> TemporalDevServerOptions.newBuilder().setUiPort(65536).build()); + assertThrows( + IllegalArgumentException.class, + () -> TemporalDevServerOptions.newBuilder().setStartupTimeout(Duration.ZERO).build()); + assertThrows( + IllegalArgumentException.class, + () -> + TemporalDevServerOptions.newBuilder() + .setDownloadCacheTtl(Duration.ofSeconds(-1)) + .build()); + assertThrows( + IllegalArgumentException.class, + () -> TemporalDevServerOptions.newBuilder().setExtraArgs("bad\nargument").build()); + } +} diff --git a/temporal-testing/src/test/java/io/temporal/testing/internal/devserver/TemporalDevServerDownloaderTest.java b/temporal-testing/src/test/java/io/temporal/testing/internal/devserver/TemporalDevServerDownloaderTest.java new file mode 100644 index 0000000000..de35b16970 --- /dev/null +++ b/temporal-testing/src/test/java/io/temporal/testing/internal/devserver/TemporalDevServerDownloaderTest.java @@ -0,0 +1,240 @@ +package io.temporal.testing.internal.devserver; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import io.temporal.testing.TemporalDevServerOptions; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; +import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class TemporalDevServerDownloaderTest { + private static final String BASE_URL_PROPERTY = "io.temporal.testing.devServerDownloadBaseUrl"; + + @TempDir Path tempDirectory; + private HttpServer httpServer; + + @AfterEach + void tearDown() { + System.clearProperty(BASE_URL_PROPERTY); + if (httpServer != null) { + httpServer.stop(0); + } + } + + @Test + void extractsRequestedFileFromTarGz() throws Exception { + byte[] contents = "#!/bin/sh\necho fake\n".getBytes(StandardCharsets.UTF_8); + Path archive = tempDirectory.resolve("synthetic.tar.gz"); + Files.write(archive, tarGz("nested/temporal", contents)); + Path extracted = tempDirectory.resolve("extracted"); + + TemporalDevServerDownloader.extractRequestedFile(archive, "nested/temporal", extracted); + + assertEquals(new String(contents, StandardCharsets.UTF_8), readString(extracted)); + } + + @Test + void fixedVersionDownloadsOnceAndConcurrentPreparationSharesCache() throws Exception { + AtomicInteger metadataRequests = new AtomicInteger(); + AtomicInteger archiveRequests = new AtomicInteger(); + byte[] executable = "#!/bin/sh\nexit 0\n".getBytes(StandardCharsets.UTF_8); + startDownloadServer("fixed-test", executable, metadataRequests, archiveRequests); + TemporalDevServerOptions options = + TemporalDevServerOptions.newBuilder() + .setDownloadVersion("fixed-test") + .setDownloadDestination(tempDirectory.resolve("cache").toString()) + .build(); + + ExecutorService executor = Executors.newFixedThreadPool(8); + try { + List> calls = new ArrayList<>(); + for (int i = 0; i < 8; i++) { + calls.add(() -> TemporalDevServerDownloader.prepare(options)); + } + List> futures = executor.invokeAll(calls); + Path expected = futures.get(0).get(); + for (Future future : futures) { + assertEquals(expected, future.get()); + } + assertEquals(1, metadataRequests.get()); + assertEquals(1, archiveRequests.get()); + + assertEquals(expected, TemporalDevServerDownloader.prepare(options)); + assertEquals(1, metadataRequests.get()); + assertEquals(1, archiveRequests.get()); + } finally { + executor.shutdownNow(); + } + } + + @Test + void defaultResolutionIncludesSdkAndFixedResolutionDoesNot() throws Exception { + AtomicInteger defaultRequests = new AtomicInteger(); + AtomicInteger archiveRequests = new AtomicInteger(); + byte[] executable = "#!/bin/sh\nexit 0\n".getBytes(StandardCharsets.UTF_8); + httpServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + httpServer.createContext( + "/cli/default", + exchange -> { + String query = exchange.getRequestURI().getRawQuery(); + assertTrue(query.contains("sdk-name=sdk-java")); + assertTrue(query.contains("sdk-version=")); + defaultRequests.incrementAndGet(); + sendJsonMetadata(exchange); + }); + httpServer.createContext( + "/archive", + exchange -> { + archiveRequests.incrementAndGet(); + send(exchange, 200, tarGz("temporal", executable)); + }); + httpServer.start(); + System.setProperty(BASE_URL_PROPERTY, baseUrl()); + + TemporalDevServerDownloader.prepare( + TemporalDevServerOptions.newBuilder() + .setDownloadDestination(tempDirectory.resolve("default-cache").toString()) + .build()); + + assertEquals(1, defaultRequests.get()); + assertEquals(1, archiveRequests.get()); + } + + @Test + void downloadDisabledFailsClearlyWhenExecutableIsAbsent() { + TemporalDevServerOptions options = + TemporalDevServerOptions.newBuilder() + .setDownloadVersion("not-present") + .setDownloadDestination(tempDirectory.resolve("disabled").toString()) + .setDownloadEnabled(false) + .build(); + + IllegalStateException failure = + assertThrows( + IllegalStateException.class, () -> TemporalDevServerDownloader.prepare(options)); + + assertTrue(failure.getMessage().contains("downloading is disabled")); + assertTrue(failure.getMessage().contains("not-present")); + } + + @Test + void expiredCacheEntryIsDownloadedAgain() throws Exception { + AtomicInteger metadataRequests = new AtomicInteger(); + AtomicInteger archiveRequests = new AtomicInteger(); + startDownloadServer( + "ttl-test", + "#!/bin/sh\nexit 0\n".getBytes(StandardCharsets.UTF_8), + metadataRequests, + archiveRequests); + TemporalDevServerOptions options = + TemporalDevServerOptions.newBuilder() + .setDownloadVersion("ttl-test") + .setDownloadDestination(tempDirectory.resolve("ttl-cache").toString()) + .setDownloadCacheTtl(Duration.ofSeconds(1)) + .build(); + Path executable = TemporalDevServerDownloader.prepare(options); + Files.setLastModifiedTime(executable, FileTime.fromMillis(System.currentTimeMillis() - 5_000)); + + assertEquals(executable, TemporalDevServerDownloader.prepare(options)); + assertEquals(2, metadataRequests.get()); + assertEquals(2, archiveRequests.get()); + } + + @Test + void cachePathContainsVersionAndPlatform() { + TemporalDevServerOptions options = + TemporalDevServerOptions.newBuilder() + .setDownloadVersion("a/version") + .setDownloadDestination(tempDirectory.toString()) + .build(); + TemporalDevServerDownloader.Platform platform = TemporalDevServerDownloader.Platform.current(); + + Path cache = TemporalDevServerDownloader.cacheDirectory(options, platform); + + assertTrue(cache.toString().contains("a_version")); + assertTrue(cache.endsWith(platform.classifier())); + } + + private void startDownloadServer( + String version, + byte[] executable, + AtomicInteger metadataRequests, + AtomicInteger archiveRequests) + throws IOException { + httpServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + httpServer.createContext( + "/cli/" + version, + exchange -> { + String query = exchange.getRequestURI().getRawQuery(); + assertTrue(query.contains("platform=")); + assertTrue(query.contains("arch=")); + assertTrue(!query.contains("sdk-name=")); + metadataRequests.incrementAndGet(); + sendJsonMetadata(exchange); + }); + httpServer.createContext( + "/archive", + exchange -> { + archiveRequests.incrementAndGet(); + send(exchange, 200, tarGz("temporal", executable)); + }); + httpServer.start(); + System.setProperty(BASE_URL_PROPERTY, baseUrl()); + } + + private void sendJsonMetadata(HttpExchange exchange) throws IOException { + String json = "{\"archiveUrl\":\"" + baseUrl() + "/archive\",\"fileToExtract\":\"temporal\"}"; + send(exchange, 200, json.getBytes(StandardCharsets.UTF_8)); + } + + private String baseUrl() { + return "http://127.0.0.1:" + httpServer.getAddress().getPort(); + } + + private static void send(HttpExchange exchange, int status, byte[] body) throws IOException { + exchange.sendResponseHeaders(status, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + } + + private static byte[] tarGz(String name, byte[] contents) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (GzipCompressorOutputStream gzip = new GzipCompressorOutputStream(bytes); + TarArchiveOutputStream tar = new TarArchiveOutputStream(gzip)) { + TarArchiveEntry entry = new TarArchiveEntry(name); + entry.setMode(0755); + entry.setSize(contents.length); + tar.putArchiveEntry(entry); + tar.write(contents); + tar.closeArchiveEntry(); + tar.finish(); + } + return bytes.toByteArray(); + } + + private static String readString(Path path) throws IOException { + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + } +} diff --git a/temporal-testing/src/test/java/io/temporal/testing/junit5/TestWorkflowExtensionDevServerIntegrationTest.java b/temporal-testing/src/test/java/io/temporal/testing/junit5/TestWorkflowExtensionDevServerIntegrationTest.java new file mode 100644 index 0000000000..ee672eff9e --- /dev/null +++ b/temporal-testing/src/test/java/io/temporal/testing/junit5/TestWorkflowExtensionDevServerIntegrationTest.java @@ -0,0 +1,73 @@ +package io.temporal.testing.junit5; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.temporal.testing.TemporalDevServerOptions; +import io.temporal.testing.TestWorkflowEnvironment; +import io.temporal.testing.TestWorkflowExtension; +import io.temporal.testing.internal.devserver.SdkJavaTestServerProfile; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** Integration coverage for extension ownership using sdk-java's pinned real Temporal CLI. */ +@EnabledIfSystemProperty(named = SdkJavaTestServerProfile.ACTIVE_PROPERTY, matches = "true") +class TestWorkflowExtensionDevServerIntegrationTest { + private static String target; + + @RegisterExtension + static final TestWorkflowExtension EXTENSION = + TestWorkflowExtension.newBuilder().useDevServer(realServerOptions()).build(); + + @Test + void extensionUsesOwnedDevServer(TestWorkflowEnvironment environment) { + assertEquals("UnitTest", environment.getNamespace()); + assertTrue(environment.isStarted()); + target = environment.getWorkflowServiceStubs().getOptions().getTarget(); + assertTrue(canConnect(target)); + } + + @AfterAll + static void serverWasClosedByExtension() throws InterruptedException { + assertTrue(awaitClosed(target)); + } + + private static TemporalDevServerOptions realServerOptions() { + if (!SdkJavaTestServerProfile.isActive()) { + return TemporalDevServerOptions.newBuilder().setExistingPath("profile-disabled").build(); + } + return TemporalDevServerOptions.newBuilder() + .setExistingPath(SdkJavaTestServerProfile.prepare().toString()) + .setStartupTimeout(Duration.ofSeconds(60)) + .build(); + } + + private static boolean awaitClosed(String target) throws InterruptedException { + for (int i = 0; i < 50; i++) { + if (!canConnect(target)) { + return true; + } + TimeUnit.MILLISECONDS.sleep(100); + } + return false; + } + + private static boolean canConnect(String target) { + int separator = target.lastIndexOf(':'); + String host = target.substring(0, separator); + int port = Integer.parseInt(target.substring(separator + 1)); + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(host, port), 250); + return true; + } catch (IOException e) { + return false; + } + } +} From 9917cc7a2771d45be7b87f65731d35ae3eee2e07 Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Fri, 31 Jul 2026 15:03:28 -0700 Subject: [PATCH 3/9] Don't mess with test forking / spawn dev server independently --- gradle/temporalCli.gradle | 107 +++++++++++++----- .../WorkerHeartbeatDeploymentVersionTest.java | 1 - .../WorkerHeartbeatIntegrationTest.java | 1 - .../GracefulPollShutdownIntegrationTest.java | 1 - .../io/temporal/testing/TestWorkflowRule.java | 13 ++- .../internal/DevServerTestProcess.java | 21 ++++ .../devserver/SdkJavaTestServerProfile.java | 29 ++--- 7 files changed, 120 insertions(+), 53 deletions(-) create mode 100644 temporal-testing/src/main/java/io/temporal/testing/internal/DevServerTestProcess.java diff --git a/gradle/temporalCli.gradle b/gradle/temporalCli.gradle index 6ceed2b449..ffcccac9c2 100644 --- a/gradle/temporalCli.gradle +++ b/gradle/temporalCli.gradle @@ -2,23 +2,87 @@ import org.gradle.api.services.BuildService import org.gradle.api.services.BuildServiceParameters import org.gradle.api.tasks.JavaExec import org.gradle.api.tasks.testing.Test +import org.gradle.jvm.toolchain.JavaLanguageVersion + +import java.util.concurrent.TimeUnit + +abstract class TemporalDevServerTestService + implements BuildService, AutoCloseable { + interface Parameters extends BuildServiceParameters { + ListProperty getClasspath() + + RegularFileProperty getJavaExecutable() + } + + private Process process + + synchronized void start() { + if (process != null) { + if (!process.isAlive()) { + throw new GradleException( + "Temporal dev-server owner exited with code ${process.exitValue()}") + } + return + } + + List command = [ + parameters.javaExecutable.get().asFile.absolutePath, + '-cp', + parameters.classpath.get().join(File.pathSeparator), + 'io.temporal.testing.internal.DevServerTestProcess', + ].collect { it.toString() } + + process = new ProcessBuilder(command) + .redirectError(ProcessBuilder.Redirect.INHERIT) + .start() + String ready = process.inputStream.newReader('UTF-8').readLine() + if (ready != 'READY') { + stop() + throw new GradleException( + 'Temporal dev-server owner exited before readiness; ' + + 'see build/temporal-cli/server/server.log') + } + } -abstract class TemporalDevServerTestSemaphore - implements BuildService, AutoCloseable { @Override - void close() {} + synchronized void close() { + stop() + } + + private void stop() { + if (process == null) { + return + } + try { + process.outputStream.close() + if (!process.waitFor(30, TimeUnit.SECONDS)) { + process.destroyForcibly() + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt() + process.destroyForcibly() + } catch (IOException e) { + process.destroyForcibly() + } finally { + process = null + } + } } def devServerProfile = providers.gradleProperty('testServer') .map { it == 'dev-server' } .orElse(false) -def devServerCache = - file("${gradle.gradleUserHomeDir}/caches/temporal-dev-server") -def devServerWorkingDirectory = layout.buildDirectory.dir('temporal-cli/server') - -def devServerSemaphore = gradle.sharedServices.registerIfAbsent( - 'temporalDevServerTestSemaphore', TemporalDevServerTestSemaphore) { - maxParallelUsages.set(1) +def devServerJavaLauncher = project(':temporal-testing').javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(JavaVersion.current().majorVersion as int) +} +def devServerService = gradle.sharedServices.registerIfAbsent( + 'temporalDevServerTestService', TemporalDevServerTestService) { + parameters.javaExecutable.set(devServerJavaLauncher.map { it.executablePath }) + parameters.classpath.set(providers.provider { + project(':temporal-testing').sourceSets.main.runtimeClasspath.files.collect { + it.absolutePath + } + }) } def prepareDevServerTests = tasks.register('prepareDevServerTests', JavaExec) { @@ -26,12 +90,6 @@ def prepareDevServerTests = tasks.register('prepareDevServerTests', JavaExec) { description = 'Caches the repository dev server and resolves inputs required by dev-server tests.' getMainClass().set('io.temporal.testing.internal.DevServerTestPreparation') - systemProperty( - 'io.temporal.testing.internal.devServerDownloadDestination', - devServerCache.absolutePath) - systemProperty( - 'io.temporal.testing.internal.devServerDownloadEnabled', - 'true') } gradle.projectsEvaluated { @@ -60,23 +118,16 @@ gradle.projectsEvaluated { allprojects.each { candidateProject -> candidateProject.tasks.withType(Test).configureEach { - usesService(devServerSemaphore) - forkEvery = 0 - maxParallelForks = 1 + dependsOn project(':temporal-testing').tasks.named('classes') + usesService(devServerService) + doFirst { + devServerService.get().start() + } environment('USE_EXTERNAL_SERVICE', 'true') environment('TEMPORAL_SERVICE_ADDRESS', 'localhost:7233') systemProperty( 'io.temporal.testing.internal.devServerProfile', 'true') - systemProperty( - 'io.temporal.testing.internal.devServerDownloadDestination', - devServerCache.absolutePath) - systemProperty( - 'io.temporal.testing.internal.devServerDownloadEnabled', - (!gradle.startParameter.offline).toString()) - systemProperty( - 'io.temporal.testing.internal.devServerWorkingDirectory', - devServerWorkingDirectory.get().asFile.absolutePath) } candidateProject.tasks.withType(JavaExec).matching { diff --git a/temporal-sdk/src/test/java/io/temporal/worker/WorkerHeartbeatDeploymentVersionTest.java b/temporal-sdk/src/test/java/io/temporal/worker/WorkerHeartbeatDeploymentVersionTest.java index 1c13a0825e..b6788df7ba 100644 --- a/temporal-sdk/src/test/java/io/temporal/worker/WorkerHeartbeatDeploymentVersionTest.java +++ b/temporal-sdk/src/test/java/io/temporal/worker/WorkerHeartbeatDeploymentVersionTest.java @@ -46,7 +46,6 @@ public void checkServerSupportsHeartbeats() { @Rule public SDKTestWorkflowRule testWorkflowRule = SDKTestWorkflowRule.newBuilder() - .setUseExternalService(true) .setTestTimeoutSeconds(15) .setWorkflowClientOptions( WorkflowClientOptions.newBuilder() diff --git a/temporal-sdk/src/test/java/io/temporal/worker/WorkerHeartbeatIntegrationTest.java b/temporal-sdk/src/test/java/io/temporal/worker/WorkerHeartbeatIntegrationTest.java index 2684180542..2160d2901b 100644 --- a/temporal-sdk/src/test/java/io/temporal/worker/WorkerHeartbeatIntegrationTest.java +++ b/temporal-sdk/src/test/java/io/temporal/worker/WorkerHeartbeatIntegrationTest.java @@ -65,7 +65,6 @@ public void checkServerSupportsHeartbeats() { @Rule public SDKTestWorkflowRule testWorkflowRule = SDKTestWorkflowRule.newBuilder() - .setUseExternalService(true) .setTestTimeoutSeconds(15) .setWorkflowClientOptions( WorkflowClientOptions.newBuilder() diff --git a/temporal-sdk/src/test/java/io/temporal/worker/shutdown/GracefulPollShutdownIntegrationTest.java b/temporal-sdk/src/test/java/io/temporal/worker/shutdown/GracefulPollShutdownIntegrationTest.java index e72acdb6bb..86e728580e 100644 --- a/temporal-sdk/src/test/java/io/temporal/worker/shutdown/GracefulPollShutdownIntegrationTest.java +++ b/temporal-sdk/src/test/java/io/temporal/worker/shutdown/GracefulPollShutdownIntegrationTest.java @@ -33,7 +33,6 @@ public class GracefulPollShutdownIntegrationTest { @Rule public SDKTestWorkflowRule testWorkflowRule = SDKTestWorkflowRule.newBuilder() - .setUseExternalService(true) .setDoNotStart(true) .setTestTimeoutSeconds(30) .setWorkflowTypes(LoopWorkflowImpl.class) diff --git a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowRule.java b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowRule.java index 91d20e855a..dede2e1483 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowRule.java +++ b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowRule.java @@ -450,11 +450,22 @@ public Statement apply(Statement base, Description description) { new Statement() { @Override public void evaluate() throws Throwable { + Throwable testFailure = null; try { start(); base.evaluate(); + } catch (Throwable failure) { + testFailure = failure; + throw failure; } finally { - shutdown(); + try { + shutdown(); + } catch (Throwable cleanupFailure) { + if (testFailure == null) { + throw cleanupFailure; + } + testFailure.addSuppressed(cleanupFailure); + } } } }; diff --git a/temporal-testing/src/main/java/io/temporal/testing/internal/DevServerTestProcess.java b/temporal-testing/src/main/java/io/temporal/testing/internal/DevServerTestProcess.java new file mode 100644 index 0000000000..81bca5f514 --- /dev/null +++ b/temporal-testing/src/main/java/io/temporal/testing/internal/DevServerTestProcess.java @@ -0,0 +1,21 @@ +package io.temporal.testing.internal; + +import io.temporal.testing.internal.devserver.SdkJavaTestServerProfile; + +/** Process entry point that owns sdk-java's repository dev server for a Gradle invocation. */ +public final class DevServerTestProcess { + private DevServerTestProcess() {} + + public static void main(String[] args) throws Exception { + try { + SdkJavaTestServerProfile.start(); + System.out.println("READY"); + System.out.flush(); + while (System.in.read() != -1) { + // The Gradle shared service keeps stdin open for the lifetime of the build. + } + } finally { + SdkJavaTestServerProfile.shutdown(); + } + } +} diff --git a/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java b/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java index 1da0898b33..f55a58845e 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java +++ b/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java @@ -7,15 +7,9 @@ import java.util.Arrays; import java.util.List; -/** JVM-wide dev server used only by sdk-java's Gradle test profile. */ +/** Configuration and lifecycle used only by sdk-java's Gradle test profile. */ public final class SdkJavaTestServerProfile { public static final String ACTIVE_PROPERTY = "io.temporal.testing.internal.devServerProfile"; - public static final String DOWNLOAD_DESTINATION_PROPERTY = - "io.temporal.testing.internal.devServerDownloadDestination"; - public static final String DOWNLOAD_ENABLED_PROPERTY = - "io.temporal.testing.internal.devServerDownloadEnabled"; - public static final String WORKING_DIRECTORY_PROPERTY = - "io.temporal.testing.internal.devServerWorkingDirectory"; // This is intentionally the sole Temporal CLI version used by sdk-java repository tests. private static final String TEST_CLI_VERSION = "1.7.2-standalone-nexus-operations"; @@ -31,10 +25,14 @@ public static boolean isActive() { return Boolean.parseBoolean(System.getProperty(ACTIVE_PROPERTY, "false")); } - public static synchronized String getTarget() { + public static String getTarget() { if (!isActive()) { return null; } + return "localhost:7233"; + } + + public static synchronized String start() { if (server == null) { File workingDirectory = workingDirectory(); cleanDatabase(workingDirectory); @@ -79,16 +77,7 @@ private static TemporalDevServerOptions serverOptions(File workingDirectory) { } private static TemporalDevServerOptions downloadOptions() { - TemporalDevServerOptions.Builder builder = - TemporalDevServerOptions.newBuilder() - .setDownloadVersion("v" + TEST_CLI_VERSION) - .setDownloadEnabled( - Boolean.parseBoolean(System.getProperty(DOWNLOAD_ENABLED_PROPERTY, "true"))); - String destination = System.getProperty(DOWNLOAD_DESTINATION_PROPERTY); - if (destination != null) { - builder.setDownloadDestination(destination); - } - return builder.build(); + return TemporalDevServerOptions.newBuilder().setDownloadVersion("v" + TEST_CLI_VERSION).build(); } private static List repositoryServerArguments() { @@ -154,9 +143,7 @@ private static List repositoryServerArguments() { } private static File workingDirectory() { - return new File( - System.getProperty( - WORKING_DIRECTORY_PROPERTY, new File("build", "temporal-cli/server").getPath())); + return new File("build", "temporal-cli/server"); } private static void cleanDatabase(File workingDirectory) { From 581f2a15ba5632dbf47e7c35ca2b6d1a45303524 Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Fri, 31 Jul 2026 15:41:35 -0700 Subject: [PATCH 4/9] Fix merge conflict --- .../testing/internal/devserver/SdkJavaTestServerProfile.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java b/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java index f55a58845e..b8c0c9d846 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java +++ b/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java @@ -133,6 +133,10 @@ private static List repositoryServerArguments() { "--dynamic-config-value", "history.enableCHASMSignalBacklinks=true", "--dynamic-config-value", + "history.enableUpdateCallbacks=true", + "--dynamic-config-value", + "history.enableCHASMCallbacks=true", + "--dynamic-config-value", "history.enableTransitionHistory=true", "--dynamic-config-value", "frontend.enableCancelWorkerPollsOnShutdown=true", From 43fe831fb47299cdf64def78841b2b539abdb29f Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Mon, 3 Aug 2026 16:51:30 -0700 Subject: [PATCH 5/9] Update docstrings --- .../java/io/temporal/testing/TestWorkflowRule.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowRule.java b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowRule.java index dede2e1483..f6bb6a9ad5 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowRule.java +++ b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowRule.java @@ -273,6 +273,11 @@ public Builder setActivityImplementations(Object... activityImplementations) { /** * Switches between in-memory and external temporal service implementations. * + *

External-service and dev-server modes are mutually exclusive. Calling this method clears + * any selection made by {@link #useDevServer()} or {@link + * #useDevServer(TemporalDevServerOptions)}; whichever method is called last determines the + * service used by the rule. + * * @param useExternalService use external service if true. *

Default is false. */ @@ -287,6 +292,10 @@ public Builder setUseExternalService(boolean useExternalService) { * *

The rule closes the server during normal teardown. Dev-server tests do not support time * skipping. + * + *

Dev-server and external-service modes are mutually exclusive. Calling this method clears + * any selection made by {@link #setUseExternalService(boolean)}; whichever method is called + * last determines the service used by the rule. */ @Experimental public Builder useDevServer() { @@ -296,6 +305,10 @@ public Builder useDevServer() { /** * Uses an owned local Temporal dev server with the supplied options. * + *

Dev-server and external-service modes are mutually exclusive. Calling this method clears + * any selection made by {@link #setUseExternalService(boolean)}; whichever method is called + * last determines the service used by the rule. + * *

{@code
      * TestWorkflowRule.newBuilder()
      *     .useDevServer(

From 84a96690ee5ba8171c4edfe32fec8e7e9e75ba40 Mon Sep 17 00:00:00 2001
From: Spencer Judge 
Date: Mon, 3 Aug 2026 17:04:25 -0700
Subject: [PATCH 6/9] Add nonnull annotations

---
 .../temporal/testing/TemporalDevServer.java   |  9 ++--
 .../testing/TemporalDevServerOptions.java     | 27 +++++------
 .../testing/TestWorkflowEnvironment.java      |  8 ++--
 .../TestWorkflowEnvironmentInternal.java      |  2 +-
 .../testing/TestWorkflowExtension.java        |  2 +-
 .../io/temporal/testing/TestWorkflowRule.java |  2 +-
 .../internal/DevServerTestPreparation.java    |  3 +-
 .../internal/DevServerTestProcess.java        |  3 +-
 .../ExternalServiceTestConfigurator.java      |  6 ++-
 .../devserver/SdkJavaTestServerProfile.java   |  5 ++-
 .../TemporalDevServerDownloader.java          | 45 +++++++++++--------
 .../devserver/TemporalDevServerLauncher.java  | 43 +++++++++++-------
 12 files changed, 92 insertions(+), 63 deletions(-)

diff --git a/temporal-testing/src/main/java/io/temporal/testing/TemporalDevServer.java b/temporal-testing/src/main/java/io/temporal/testing/TemporalDevServer.java
index 1bc1709351..68d506d9da 100644
--- a/temporal-testing/src/main/java/io/temporal/testing/TemporalDevServer.java
+++ b/temporal-testing/src/main/java/io/temporal/testing/TemporalDevServer.java
@@ -2,6 +2,7 @@
 
 import io.temporal.common.Experimental;
 import io.temporal.testing.internal.devserver.TemporalDevServerLauncher;
+import javax.annotation.Nonnull;
 
 /**
  * A local Temporal dev server owned by the calling process.
@@ -21,7 +22,8 @@ public final class TemporalDevServer implements AutoCloseable {
   private final String namespace;
   private final AutoCloseable owner;
 
-  private TemporalDevServer(String target, String namespace, AutoCloseable owner) {
+  private TemporalDevServer(
+      @Nonnull String target, @Nonnull String namespace, @Nonnull AutoCloseable owner) {
     this.target = target;
     this.namespace = namespace;
     this.owner = owner;
@@ -33,12 +35,13 @@ public static TemporalDevServer start() {
   }
 
   /** Starts a dev server in namespace {@code default} with the supplied options. */
-  public static TemporalDevServer start(TemporalDevServerOptions options) {
+  public static TemporalDevServer start(@Nonnull TemporalDevServerOptions options) {
     return start("default", options);
   }
 
   /** Starts a dev server for the supplied namespace and options. */
-  public static TemporalDevServer start(String namespace, TemporalDevServerOptions options) {
+  public static TemporalDevServer start(
+      @Nonnull String namespace, @Nonnull TemporalDevServerOptions options) {
     if (namespace == null || namespace.trim().isEmpty()) {
       throw new IllegalArgumentException("namespace cannot be blank");
     }
diff --git a/temporal-testing/src/main/java/io/temporal/testing/TemporalDevServerOptions.java b/temporal-testing/src/main/java/io/temporal/testing/TemporalDevServerOptions.java
index 7bd90cd8e5..2d89f2f941 100644
--- a/temporal-testing/src/main/java/io/temporal/testing/TemporalDevServerOptions.java
+++ b/temporal-testing/src/main/java/io/temporal/testing/TemporalDevServerOptions.java
@@ -5,6 +5,7 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 
 /** Options for a {@link TemporalDevServer}. */
@@ -16,7 +17,7 @@ public static Builder newBuilder() {
     return new Builder();
   }
 
-  public static Builder newBuilder(TemporalDevServerOptions options) {
+  public static Builder newBuilder(@Nonnull TemporalDevServerOptions options) {
     return new Builder(options);
   }
 
@@ -44,7 +45,7 @@ public static final class Builder {
 
     private Builder() {}
 
-    private Builder(TemporalDevServerOptions options) {
+    private Builder(@Nonnull TemporalDevServerOptions options) {
       if (options == null) {
         throw new NullPointerException("options");
       }
@@ -76,7 +77,7 @@ public Builder setExistingPath(@Nullable String existingPath) {
      * Sets the CLI version to download. {@code "default"} selects the version associated with the
      * running sdk-java version; any other non-empty value is sent to temporal.download unchanged.
      */
-    public Builder setDownloadVersion(String downloadVersion) {
+    public Builder setDownloadVersion(@Nonnull String downloadVersion) {
       this.downloadVersion = downloadVersion;
       return this;
     }
@@ -105,13 +106,13 @@ public Builder setDownloadEnabled(boolean downloadEnabled) {
     }
 
     /** Sets the IP address on which the dev server listens. */
-    public Builder setIp(String ip) {
+    public Builder setIp(@Nonnull String ip) {
       this.ip = ip;
       return this;
     }
 
     /** Alias for {@link #setIp(String)}. */
-    public Builder setBindIp(String ip) {
+    public Builder setBindIp(@Nonnull String ip) {
       return setIp(ip);
     }
 
@@ -148,13 +149,13 @@ public Builder setUiPort(@Nullable Integer uiPort) {
     }
 
     /** Sets the Temporal CLI log format. Defaults to {@code pretty}. */
-    public Builder setLogFormat(String logFormat) {
+    public Builder setLogFormat(@Nonnull String logFormat) {
       this.logFormat = logFormat;
       return this;
     }
 
     /** Sets the Temporal CLI log level. Defaults to {@code warn}. */
-    public Builder setLogLevel(String logLevel) {
+    public Builder setLogLevel(@Nonnull String logLevel) {
       this.logLevel = logLevel;
       return this;
     }
@@ -172,13 +173,13 @@ public Builder setLogFile(@Nullable String logFile) {
     }
 
     /** Sets the single timeout used for health and namespace readiness checks. */
-    public Builder setStartupTimeout(Duration startupTimeout) {
+    public Builder setStartupTimeout(@Nonnull Duration startupTimeout) {
       this.startupTimeout = startupTimeout;
       return this;
     }
 
     /** Sets additional arguments appended to the generated {@code server start-dev} command. */
-    public Builder setExtraArgs(List extraArgs) {
+    public Builder setExtraArgs(@Nonnull List extraArgs) {
       if (extraArgs == null) {
         throw new NullPointerException("extraArgs");
       }
@@ -187,7 +188,7 @@ public Builder setExtraArgs(List extraArgs) {
     }
 
     /** Sets additional arguments appended to the generated {@code server start-dev} command. */
-    public Builder setExtraArgs(String... extraArgs) {
+    public Builder setExtraArgs(@Nonnull String... extraArgs) {
       if (extraArgs == null) {
         throw new NullPointerException("extraArgs");
       }
@@ -235,13 +236,13 @@ public TemporalDevServerOptions build() {
       return new TemporalDevServerOptions(this);
     }
 
-    private static void requireNonBlank(String value, String name) {
+    private static void requireNonBlank(@Nullable String value, @Nonnull String name) {
       if (value == null || value.trim().isEmpty()) {
         throw new IllegalArgumentException(name + " cannot be blank");
       }
     }
 
-    private static void validatePort(Integer port, String name) {
+    private static void validatePort(@Nullable Integer port, @Nonnull String name) {
       if (port != null && (port < 1 || port > 65535)) {
         throw new IllegalArgumentException(name + " must be between 1 and 65535");
       }
@@ -265,7 +266,7 @@ private static void validatePort(Integer port, String name) {
   private final Duration startupTimeout;
   private final List extraArgs;
 
-  private TemporalDevServerOptions(Builder builder) {
+  private TemporalDevServerOptions(@Nonnull Builder builder) {
     this.existingPath = builder.existingPath;
     this.downloadVersion = builder.downloadVersion;
     this.downloadDestination = builder.downloadDestination;
diff --git a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironment.java b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironment.java
index d3f3adaac8..cca5624499 100644
--- a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironment.java
+++ b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironment.java
@@ -16,6 +16,7 @@
 import java.time.Duration;
 import java.util.concurrent.TimeUnit;
 import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
 
 /**
  * TestWorkflowEnvironment provides workflow unit testing capabilities.
@@ -113,7 +114,7 @@ static TestWorkflowEnvironment startLocal() {
    * that owns it. Local dev-server environments do not support time skipping.
    */
   @Experimental
-  static TestWorkflowEnvironment startLocal(TestEnvironmentOptions testOptions) {
+  static TestWorkflowEnvironment startLocal(@Nullable TestEnvironmentOptions testOptions) {
     return startLocal(testOptions, TemporalDevServerOptions.getDefaultInstance());
   }
 
@@ -122,7 +123,7 @@ static TestWorkflowEnvironment startLocal(TestEnvironmentOptions testOptions) {
    * environments do not support time skipping.
    */
   @Experimental
-  static TestWorkflowEnvironment startLocal(TemporalDevServerOptions serverOptions) {
+  static TestWorkflowEnvironment startLocal(@Nonnull TemporalDevServerOptions serverOptions) {
     return startLocal(TestEnvironmentOptions.getDefaultInstance(), serverOptions);
   }
 
@@ -134,7 +135,8 @@ static TestWorkflowEnvironment startLocal(TemporalDevServerOptions serverOptions
    */
   @Experimental
   static TestWorkflowEnvironment startLocal(
-      TestEnvironmentOptions testOptions, TemporalDevServerOptions serverOptions) {
+      @Nullable TestEnvironmentOptions testOptions,
+      @Nonnull TemporalDevServerOptions serverOptions) {
     if (testOptions == null) {
       testOptions = TestEnvironmentOptions.getDefaultInstance();
     }
diff --git a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironmentInternal.java b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironmentInternal.java
index 8b54f078bd..c848bfeb12 100644
--- a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironmentInternal.java
+++ b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironmentInternal.java
@@ -340,7 +340,7 @@ public void close() {
   }
 
   private static RuntimeException runCleanup(
-      @Nullable RuntimeException previousFailure, Runnable cleanup) {
+      @Nullable RuntimeException previousFailure, @Nonnull Runnable cleanup) {
     try {
       cleanup.run();
     } catch (RuntimeException failure) {
diff --git a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowExtension.java b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowExtension.java
index b77849d702..22c5aae9b0 100644
--- a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowExtension.java
+++ b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowExtension.java
@@ -502,7 +502,7 @@ public Builder useDevServer() {
      * }
*/ @Experimental - public Builder useDevServer(TemporalDevServerOptions options) { + public Builder useDevServer(@Nonnull TemporalDevServerOptions options) { if (options == null) { throw new NullPointerException("options"); } diff --git a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowRule.java b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowRule.java index f6bb6a9ad5..77ff7139ff 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowRule.java +++ b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowRule.java @@ -318,7 +318,7 @@ public Builder useDevServer() { * } */ @Experimental - public Builder useDevServer(TemporalDevServerOptions options) { + public Builder useDevServer(@Nonnull TemporalDevServerOptions options) { if (options == null) { throw new NullPointerException("options"); } diff --git a/temporal-testing/src/main/java/io/temporal/testing/internal/DevServerTestPreparation.java b/temporal-testing/src/main/java/io/temporal/testing/internal/DevServerTestPreparation.java index 63706143f5..7001f0361b 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/internal/DevServerTestPreparation.java +++ b/temporal-testing/src/main/java/io/temporal/testing/internal/DevServerTestPreparation.java @@ -1,12 +1,13 @@ package io.temporal.testing.internal; import io.temporal.testing.internal.devserver.SdkJavaTestServerProfile; +import javax.annotation.Nonnull; /** Download-only entry point used by sdk-java's {@code prepareDevServerTests} task. */ public final class DevServerTestPreparation { private DevServerTestPreparation() {} - public static void main(String[] args) { + public static void main(@Nonnull String[] args) { System.out.println( "Prepared Temporal CLI at " + SdkJavaTestServerProfile.prepare().toAbsolutePath()); } diff --git a/temporal-testing/src/main/java/io/temporal/testing/internal/DevServerTestProcess.java b/temporal-testing/src/main/java/io/temporal/testing/internal/DevServerTestProcess.java index 81bca5f514..55003fab0c 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/internal/DevServerTestProcess.java +++ b/temporal-testing/src/main/java/io/temporal/testing/internal/DevServerTestProcess.java @@ -1,12 +1,13 @@ package io.temporal.testing.internal; import io.temporal.testing.internal.devserver.SdkJavaTestServerProfile; +import javax.annotation.Nonnull; /** Process entry point that owns sdk-java's repository dev server for a Gradle invocation. */ public final class DevServerTestProcess { private DevServerTestProcess() {} - public static void main(String[] args) throws Exception { + public static void main(@Nonnull String[] args) throws Exception { try { SdkJavaTestServerProfile.start(); System.out.println("READY"); diff --git a/temporal-testing/src/main/java/io/temporal/testing/internal/ExternalServiceTestConfigurator.java b/temporal-testing/src/main/java/io/temporal/testing/internal/ExternalServiceTestConfigurator.java index 0f5f9d4605..6c68d5f2b5 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/internal/ExternalServiceTestConfigurator.java +++ b/temporal-testing/src/main/java/io/temporal/testing/internal/ExternalServiceTestConfigurator.java @@ -4,6 +4,7 @@ import io.temporal.testing.TestEnvironmentOptions; import io.temporal.testing.TestWorkflowRule; import io.temporal.testing.internal.devserver.SdkJavaTestServerProfile; +import javax.annotation.Nonnull; public class ExternalServiceTestConfigurator { private static boolean USE_EXTERNAL_SERVICE = @@ -30,7 +31,8 @@ public static String getTemporalServiceAddress() { : null; } - public static TestWorkflowRule.Builder configure(TestWorkflowRule.Builder testWorkflowRule) { + public static TestWorkflowRule.Builder configure( + @Nonnull TestWorkflowRule.Builder testWorkflowRule) { if (isUseExternalService()) { testWorkflowRule.setUseExternalService(true); String target = getTemporalServiceAddress(); @@ -42,7 +44,7 @@ public static TestWorkflowRule.Builder configure(TestWorkflowRule.Builder testWo } public static TestEnvironmentOptions.Builder configure( - TestEnvironmentOptions.Builder testEnvironmentOptions) { + @Nonnull TestEnvironmentOptions.Builder testEnvironmentOptions) { if (isUseExternalService()) { testEnvironmentOptions.setUseExternalService(true); String target = getTemporalServiceAddress(); diff --git a/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java b/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java index b8c0c9d846..5e2bb45790 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java +++ b/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java @@ -6,6 +6,7 @@ import java.nio.file.Path; import java.util.Arrays; import java.util.List; +import javax.annotation.Nonnull; /** Configuration and lifecycle used only by sdk-java's Gradle test profile. */ public final class SdkJavaTestServerProfile { @@ -64,7 +65,7 @@ public static synchronized void shutdown() { cleanDatabase(workingDirectory()); } - private static TemporalDevServerOptions serverOptions(File workingDirectory) { + private static TemporalDevServerOptions serverOptions(@Nonnull File workingDirectory) { return TemporalDevServerOptions.newBuilder(downloadOptions()) .setIp("127.0.0.1") .setPort(7233) @@ -150,7 +151,7 @@ private static File workingDirectory() { return new File("build", "temporal-cli/server"); } - private static void cleanDatabase(File workingDirectory) { + private static void cleanDatabase(@Nonnull File workingDirectory) { for (String name : Arrays.asList(DATABASE_FILENAME, DATABASE_FILENAME + "-shm", DATABASE_FILENAME + "-wal")) { File file = new File(workingDirectory, name); diff --git a/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/TemporalDevServerDownloader.java b/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/TemporalDevServerDownloader.java index 6833fa249a..02a97a1837 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/TemporalDevServerDownloader.java +++ b/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/TemporalDevServerDownloader.java @@ -28,6 +28,8 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.zip.GZIPInputStream; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; import org.apache.commons.compress.archivers.ArchiveEntry; import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream; @@ -42,7 +44,7 @@ public final class TemporalDevServerDownloader { private TemporalDevServerDownloader() {} - public static Path prepare(TemporalDevServerOptions options) { + public static Path prepare(@Nonnull TemporalDevServerOptions options) { String existingPath = options.getExistingPath(); if (existingPath != null) { Path executable = new File(existingPath).toPath().toAbsolutePath().normalize(); @@ -97,7 +99,8 @@ public static Path prepare(TemporalDevServerOptions options) { } } - static Path cacheDirectory(TemporalDevServerOptions options, Platform platform) { + static Path cacheDirectory( + @Nonnull TemporalDevServerOptions options, @Nonnull Platform platform) { String destination = options.getDownloadDestination(); Path root = destination == null @@ -110,7 +113,8 @@ static Path cacheDirectory(TemporalDevServerOptions options, Platform platform) return root.toAbsolutePath().normalize().resolve(version).resolve(platform.classifier()); } - private static boolean isUsableCacheEntry(Path executable, Duration ttl) throws IOException { + private static boolean isUsableCacheEntry(@Nonnull Path executable, @Nullable Duration ttl) + throws IOException { if (!Files.isRegularFile(executable)) { return false; } @@ -125,8 +129,8 @@ private static boolean isUsableCacheEntry(Path executable, Duration ttl) throws return ageMillis <= ttl.toMillis(); } - private static DownloadInfo getDownloadInfo(TemporalDevServerOptions options, Platform platform) - throws IOException { + private static DownloadInfo getDownloadInfo( + @Nonnull TemporalDevServerOptions options, @Nonnull Platform platform) throws IOException { String version = encodeQueryValue(options.getDownloadVersion()).replace("+", "%20"); StringBuilder url = new StringBuilder( @@ -162,7 +166,8 @@ private static DownloadInfo getDownloadInfo(TemporalDevServerOptions options, Pl } } - private static void downloadAndExtract(DownloadInfo info, Path executable, Path cacheDirectory) + private static void downloadAndExtract( + @Nonnull DownloadInfo info, @Nonnull Path executable, @Nonnull Path cacheDirectory) throws IOException { Path archive = cacheDirectory.resolve("archive-" + UUID.randomUUID().toString() + ".downloading"); @@ -196,7 +201,8 @@ private static void downloadAndExtract(DownloadInfo info, Path executable, Path } } - static void extractRequestedFile(Path archive, String requestedName, Path destination) + static void extractRequestedFile( + @Nonnull Path archive, @Nonnull String requestedName, @Nonnull Path destination) throws IOException { try (BufferedInputStream input = new BufferedInputStream(new FileInputStream(archive.toFile()))) { @@ -217,9 +223,9 @@ static void extractRequestedFile(Path archive, String requestedName, Path destin } private static void extractEntry( - org.apache.commons.compress.archivers.ArchiveInputStream archive, - String requestedName, - Path destination) + @Nonnull org.apache.commons.compress.archivers.ArchiveInputStream archive, + @Nonnull String requestedName, + @Nonnull Path destination) throws IOException { String normalizedRequested = normalizeArchiveName(requestedName); ArchiveEntry entry; @@ -236,7 +242,7 @@ && normalizeArchiveName(entry.getName()).equals(normalizedRequested)) { throw new IOException("CLI archive did not contain " + requestedName); } - private static String normalizeArchiveName(String name) { + private static String normalizeArchiveName(@Nonnull String name) { String normalized = name.replace('\\', '/'); while (normalized.startsWith("./")) { normalized = normalized.substring(2); @@ -244,7 +250,7 @@ private static String normalizeArchiveName(String name) { return normalized; } - private static HttpURLConnection openFollowingRedirects(String url) throws IOException { + private static HttpURLConnection openFollowingRedirects(@Nonnull String url) throws IOException { String next = url; for (int redirects = 0; redirects <= 5; redirects++) { HttpURLConnection connection = (HttpURLConnection) URI.create(next).toURL().openConnection(); @@ -271,7 +277,8 @@ private static HttpURLConnection openFollowingRedirects(String url) throws IOExc throw new IOException("Too many redirects downloading " + url); } - private static void atomicMove(Path source, Path destination) throws IOException { + private static void atomicMove(@Nonnull Path source, @Nonnull Path destination) + throws IOException { try { Files.move( source, destination, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); @@ -280,7 +287,8 @@ private static void atomicMove(Path source, Path destination) throws IOException } } - private static void copy(InputStream input, OutputStream output) throws IOException { + private static void copy(@Nonnull InputStream input, @Nonnull OutputStream output) + throws IOException { byte[] buffer = new byte[8192]; int read; while ((read = input.read(buffer)) != -1) { @@ -288,7 +296,7 @@ private static void copy(InputStream input, OutputStream output) throws IOExcept } } - private static String encodeQueryValue(String value) { + private static String encodeQueryValue(@Nonnull String value) { try { return URLEncoder.encode(value, "UTF-8"); } catch (java.io.UnsupportedEncodingException e) { @@ -296,11 +304,11 @@ private static String encodeQueryValue(String value) { } } - private static String safePathPart(String value) { + private static String safePathPart(@Nonnull String value) { return value.replaceAll("[^A-Za-z0-9._-]", "_"); } - private static boolean isBlank(String value) { + private static boolean isBlank(@Nullable String value) { return value == null || value.trim().isEmpty(); } @@ -318,7 +326,8 @@ static final class Platform { private final String architecture; private final String executableName; - private Platform(String platform, String architecture, String executableName) { + private Platform( + @Nonnull String platform, @Nonnull String architecture, @Nonnull String executableName) { this.platform = platform; this.architecture = architecture; this.executableName = executableName; diff --git a/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/TemporalDevServerLauncher.java b/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/TemporalDevServerLauncher.java index c47c2da080..58cd6c9ba1 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/TemporalDevServerLauncher.java +++ b/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/TemporalDevServerLauncher.java @@ -24,6 +24,8 @@ import java.util.List; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; /** Internal ProcessBuilder-based Temporal dev-server launcher. */ public final class TemporalDevServerLauncher { @@ -32,7 +34,8 @@ public final class TemporalDevServerLauncher { private TemporalDevServerLauncher() {} - public static RunningServer start(String namespace, TemporalDevServerOptions options) { + public static RunningServer start( + @Nonnull String namespace, @Nonnull TemporalDevServerOptions options) { Path executable = TemporalDevServerDownloader.prepare(options); int port = options.getPort() == null ? reservePort(options.getIp()) : options.getPort(); String target = targetHost(options.getIp()) + ":" + port; @@ -65,8 +68,8 @@ public static RunningServer start(String namespace, TemporalDevServerOptions opt } } - private static void configureOutput(ProcessBuilder processBuilder, File logFile) - throws IOException { + private static void configureOutput( + @Nonnull ProcessBuilder processBuilder, @Nullable File logFile) throws IOException { if (logFile == null) { processBuilder.redirectOutput(ProcessBuilder.Redirect.INHERIT); return; @@ -79,7 +82,10 @@ private static void configureOutput(ProcessBuilder processBuilder, File logFile) } static List buildCommand( - Path executable, String namespace, TemporalDevServerOptions options, int port) { + @Nonnull Path executable, + @Nonnull String namespace, + @Nonnull TemporalDevServerOptions options, + int port) { List command = new ArrayList<>(); command.add(executable.toAbsolutePath().toString()); command.add("server"); @@ -119,12 +125,12 @@ static List buildCommand( } private static void waitUntilReady( - Process process, - String target, - String namespace, - TemporalDevServerOptions options, - List command, - File logFile) { + @Nonnull Process process, + @Nonnull String target, + @Nonnull String namespace, + @Nonnull TemporalDevServerOptions options, + @Nonnull List command, + @Nullable File logFile) { long timeoutNanos = options.getStartupTimeout().toNanos(); long startNanos = System.nanoTime(); long deadlineNanos = @@ -186,7 +192,10 @@ private static void waitUntilReady( } private static IllegalStateException startupFailure( - String message, List command, File logFile, Throwable cause) { + @Nonnull String message, + @Nonnull List command, + @Nullable File logFile, + @Nullable Throwable cause) { return new IllegalStateException( message + "\nCommand: " @@ -196,7 +205,7 @@ private static IllegalStateException startupFailure( cause); } - private static String readOutputTail(File logFile) { + private static String readOutputTail(@Nullable File logFile) { if (logFile == null) { return ""; } @@ -223,7 +232,7 @@ private static String readOutputTail(File logFile) { return String.join(System.lineSeparator(), tail); } - private static String renderCommand(List command) { + private static String renderCommand(@Nonnull List command) { StringBuilder rendered = new StringBuilder(); for (String part : command) { if (rendered.length() > 0) { @@ -238,7 +247,7 @@ private static String renderCommand(List command) { return rendered.toString(); } - private static int reservePort(String ip) { + private static int reservePort(@Nonnull String ip) { try (ServerSocket socket = new ServerSocket(0, 0, InetAddress.getByName(ip))) { socket.setReuseAddress(true); return socket.getLocalPort(); @@ -247,14 +256,14 @@ private static int reservePort(String ip) { } } - private static String targetHost(String ip) { + private static String targetHost(@Nonnull String ip) { if ("0.0.0.0".equals(ip) || "::".equals(ip) || "0:0:0:0:0:0:0:0".equals(ip)) { return "127.0.0.1"; } return ip.indexOf(':') >= 0 ? "[" + ip + "]" : ip; } - private static void stopProcess(Process process) { + private static void stopProcess(@Nullable Process process) { if (process == null || !process.isAlive()) { return; } @@ -275,7 +284,7 @@ public static final class RunningServer implements AutoCloseable { private final Process process; private final AtomicBoolean closed = new AtomicBoolean(); - private RunningServer(String target, Process process) { + private RunningServer(@Nonnull String target, @Nonnull Process process) { this.target = target; this.process = process; } From 363ebdd12bd6c1f998aa7e88011fa66c43fe187c Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Mon, 3 Aug 2026 17:04:57 -0700 Subject: [PATCH 7/9] Fix version string --- .../src/main/java/io/temporal/testing/TestWorkflowRule.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowRule.java b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowRule.java index 77ff7139ff..1ad42e4e8f 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowRule.java +++ b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowRule.java @@ -312,7 +312,7 @@ public Builder useDevServer() { *
{@code
      * TestWorkflowRule.newBuilder()
      *     .useDevServer(
-     *         TemporalDevServerOptions.newBuilder().setDownloadVersion("1.7.2").build())
+     *         TemporalDevServerOptions.newBuilder().setDownloadVersion("v1.7.2").build())
      *     .setWorkflowTypes(MyWorkflowImpl.class)
      *     .build();
      * }
From eaa53d4549cd038b093c535dd95b03d31740d132 Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Tue, 4 Aug 2026 09:25:31 -0700 Subject: [PATCH 8/9] Fix merge conflict --- .../testing/internal/devserver/SdkJavaTestServerProfile.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java b/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java index 5e2bb45790..c2b88b69c3 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java +++ b/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java @@ -126,6 +126,8 @@ private static List repositoryServerArguments() { "--dynamic-config-value", "activity.enableStandalone=true", "--dynamic-config-value", + "activity.enableCallbacks=true", + "--dynamic-config-value", "activity.startDelayEnabled=true", "--dynamic-config-value", "nexusoperation.enableStandalone=true", From 49a200178aa3c90e153338b662f8e1f70c8a330b Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Tue, 4 Aug 2026 16:43:26 -0700 Subject: [PATCH 9/9] Update server version to needed one --- .../testing/internal/devserver/SdkJavaTestServerProfile.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java b/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java index c2b88b69c3..18215bd2f8 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java +++ b/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java @@ -13,7 +13,7 @@ public final class SdkJavaTestServerProfile { public static final String ACTIVE_PROPERTY = "io.temporal.testing.internal.devServerProfile"; // This is intentionally the sole Temporal CLI version used by sdk-java repository tests. - private static final String TEST_CLI_VERSION = "1.7.2-standalone-nexus-operations"; + private static final String TEST_CLI_VERSION = "1.7.4-standalone-nexus-operations"; private static final String TEST_NAMESPACE = "UnitTest"; private static final String DATABASE_FILENAME = "temporal.sqlite";