diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1affe11 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea/ +.claude/ +*.iml +CLAUDE.md diff --git a/kotlin/README.md b/kotlin/README.md new file mode 100644 index 0000000..ac715be --- /dev/null +++ b/kotlin/README.md @@ -0,0 +1,134 @@ +# Kotlin SDK API Proposal + +This document describes the public API and developer experience for the Temporal Kotlin SDK. + +## Overview + +The Kotlin SDK provides an idiomatic Kotlin experience for building Temporal workflows using coroutines and suspend functions. + +**Key Features:** + +* Coroutine-based workflows with `suspend fun` +* Full interoperability with Java SDK +* Kotlin Duration support (`30.seconds`) +* DSL builders for configuration +* Null safety (no `Optional`) + +**Requirements:** + +* Minimum Kotlin version: 1.8.x +* Coroutines library: kotlinx-coroutines-core 1.7.x+ + +## Design Principle + +**Use idiomatic Kotlin language patterns wherever possible instead of custom APIs.** + +The Kotlin SDK should feel natural to Kotlin developers by leveraging standard `kotlinx.coroutines` primitives. Custom APIs should only be introduced when Temporal-specific semantics cannot be achieved through standard patterns. + +| Pattern | Standard Kotlin | Temporal Integration | +|---------|-----------------|----------------------| +| Parallel execution | `coroutineScope { async { ... } }` | Works via deterministic dispatcher | +| Await multiple | `awaitAll(d1, d2)` | Standard kotlinx.coroutines | +| Sleep/delay | `delay(duration)` | Intercepted via `Delay` interface | +| Deferred results | `Deferred` | Standard + `Promise.toDeferred()` | + +This approach provides: +- **Familiar patterns**: Kotlin developers use patterns they already know +- **IDE support**: Full autocomplete and documentation for standard APIs +- **Ecosystem compatibility**: Works with existing coroutine libraries and utilities +- **Smaller API surface**: Less custom code to learn and maintain + +## Documentation Structure + +### Core Concepts + +- **[Kotlin Idioms](./kotlin-idioms.md)** - Duration, null safety, property syntax for queries +- **[Configuration](./configuration/README.md)** - KOptions classes, data conversion, interceptors + +### Building Blocks + +- **[Workflows](./workflows/README.md)** - Defining and implementing workflows + - [Definition](./workflows/definition.md) - Interfaces, suspend methods, Java interop + - [Signals, Queries & Updates](./workflows/signals-queries.md) - Communication patterns + - [Child Workflows](./workflows/child-workflows.md) - Orchestrating child workflows + - [Timers & Parallel Execution](./workflows/timers-parallel.md) - Delays, async patterns + - [Cancellation](./workflows/cancellation.md) - Handling cancellation, cleanup + - [Continue-As-New](./workflows/continue-as-new.md) - Long-running workflow patterns + +- **[Activities](./activities/README.md)** - Defining and implementing activities + - [Definition](./activities/definition.md) - Interfaces, typed/string-based execution + - [Implementation](./activities/implementation.md) - Suspend activities, heartbeating + - [Local Activities](./activities/local-activities.md) - Short-lived local activities + +### Infrastructure + +- **[Client](./client/README.md)** - Interacting with workflows + - [Workflow Client](./client/workflow-client.md) - KClient, starting workflows + - [Workflow Handles](./client/workflow-handle.md) - Signals, queries, results + - [Advanced Operations](./client/advanced.md) - SignalWithStart, UpdateWithStart + +- **[Worker](./worker/README.md)** - Running workflows and activities + - [Setup](./worker/setup.md) - KWorkerFactory, KWorker, registration + +### Reference + +- **[Testing](./testing.md)** - Unit testing, mocking activities, time skipping +- **[Migration Guide](./migration.md)** - Migrating from Java SDK +- **[API Parity](./api-parity.md)** - Java SDK comparison, gaps, not-needed APIs +- **[Open Questions](./open-questions.md)** - API design decisions pending discussion + +## Quick Start + +```kotlin +// Define activity interface - @ActivityMethod is optional +@ActivityInterface +interface GreetingActivities { + suspend fun composeGreeting(greeting: String, name: String): String +} + +// Implement activity +class GreetingActivitiesImpl : GreetingActivities { + override suspend fun composeGreeting(greeting: String, name: String): String { + return "$greeting, $name!" + } +} + +// Define workflow interface - @WorkflowMethod is optional +@WorkflowInterface +interface GreetingWorkflow { + suspend fun getGreeting(name: String): String +} + +// Implement workflow +class GreetingWorkflowImpl : GreetingWorkflow { + override suspend fun getGreeting(name: String): String { + return KWorkflow.executeActivity( + GreetingActivities::composeGreeting, + KActivityOptions(startToCloseTimeout = 10.seconds), + "Hello", name + ) + } +} + +// Start worker - workflows and activities specified in options +val worker = KWorker( + client, + KWorkerOptions( + taskQueue = "greetings", + workflows = listOf(GreetingWorkflowImpl::class), + activities = listOf(GreetingActivitiesImpl()) + ) +) +worker.start() + +// Execute workflow +val result = client.executeWorkflow( + GreetingWorkflow::getGreeting, + KWorkflowOptions(workflowId = "greeting-123", taskQueue = "greetings"), + "Temporal" +) +``` + +--- + +**[Start Review →](./kotlin-idioms.md)** diff --git a/kotlin/activities/README.md b/kotlin/activities/README.md new file mode 100644 index 0000000..1d9bd67 --- /dev/null +++ b/kotlin/activities/README.md @@ -0,0 +1,77 @@ +# Activities + +This section covers defining and implementing Temporal activities in Kotlin. + +## Overview + +Activities are the building blocks for interacting with external systems. The Kotlin SDK provides type-safe activity execution with suspend function support. + +## Documents + +| Document | Description | +|----------|-------------| +| [Definition](./definition.md) | Activity interfaces, typed and string-based execution | +| [Implementation](./implementation.md) | Implementing activities, KActivity API, heartbeating | +| [Local Activities](./local-activities.md) | Short-lived local activities | + +## Quick Reference + +### Basic Activity + +```kotlin +// Define activity interface - @ActivityMethod is optional +@ActivityInterface +interface GreetingActivities { + suspend fun composeGreeting(greeting: String, name: String): String +} + +// Use @ActivityMethod only when customizing the activity name +@ActivityInterface +interface CustomNameActivities { + @ActivityMethod(name = "compose-greeting") + suspend fun composeGreeting(greeting: String, name: String): String +} + +// Implement activity +class GreetingActivitiesImpl : GreetingActivities { + override suspend fun composeGreeting(greeting: String, name: String): String { + return "$greeting, $name!" + } +} +``` + +### Calling Activities from Workflows + +```kotlin +// Type-safe method reference +val greeting = KWorkflow.executeActivity( + GreetingActivities::composeGreeting, + KActivityOptions(startToCloseTimeout = 30.seconds), + "Hello", "World" +) + +// String-based (for cross-language interop) +val result = KWorkflow.executeActivity( + "composeGreeting", + KActivityOptions(startToCloseTimeout = 30.seconds), + "Hello", "World" +) +``` + +### Key Patterns + +| Pattern | API | +|---------|-----| +| Execute activity | `KWorkflow.executeActivity(Interface::method, options, args)` | +| Execute by name | `KWorkflow.executeActivity("name", options, args)` | +| Local activity | `KWorkflow.executeLocalActivity(Interface::method, options, args)` | +| Heartbeat | `KActivity.executionContext.heartbeat(details)` | + +## Related + +- [Implementation](./implementation.md) - Suspend activity patterns +- [Local Activities](./local-activities.md) - Short-lived activities + +--- + +**Next:** [Activity Definition](./definition.md) diff --git a/kotlin/activities/definition.md b/kotlin/activities/definition.md new file mode 100644 index 0000000..60a2ce8 --- /dev/null +++ b/kotlin/activities/definition.md @@ -0,0 +1,274 @@ +# Activity Definition + +## String-based Activity Execution + +For calling activities by name (useful for cross-language interop or dynamic activity names): + +```kotlin +// Execute activity by string name - suspend function, awaits result +val result = KWorkflow.executeActivity( + "activityName", + KActivityOptions( + startToCloseTimeout = 30.seconds, + retryOptions = KRetryOptions( + initialInterval = 1.seconds, + maximumAttempts = 3 + ) + ), + arg1, arg2 +) + +// Parallel execution - use standard coroutineScope { async {} } +val results = coroutineScope { + val d1 = async { KWorkflow.executeActivity("activity1", options, arg1) } + val d2 = async { KWorkflow.executeActivity("activity2", options, arg2) } + awaitAll(d1, d2) // Returns List +} +``` + +## Typed Activities + +The typed activity API uses direct method references - no stub creation needed. This approach: +- Provides full compile-time type safety for arguments and return types +- Allows different options (timeouts, retry policies) per activity call +- Works with both Kotlin `suspend` and Java non-suspend activity interfaces +- Similar to TypeScript and Python SDK patterns + +```kotlin +// Define activity interface +@ActivityInterface +interface GreetingActivities { + suspend fun composeGreeting(greeting: String, name: String): String + + suspend fun sendEmail(email: Email): SendResult + + suspend fun log(message: String) +} + +// Use @ActivityMethod only when customizing the activity name +@ActivityInterface +interface CustomNameActivities { + @ActivityMethod(name = "compose-greeting") + suspend fun composeGreeting(greeting: String, name: String): String +} + +// In workflow - direct method reference, no stub needed +val greeting = KWorkflow.executeActivity( + GreetingActivities::composeGreeting, // Direct reference to interface method + KActivityOptions(startToCloseTimeout = 30.seconds), + "Hello", "World" +) + +// Different activity, different options +val result = KWorkflow.executeActivity( + GreetingActivities::sendEmail, + KActivityOptions( + startToCloseTimeout = 2.minutes, + retryOptions = KRetryOptions(maximumAttempts = 5) + ), + email +) + +// Void activities work too +KWorkflow.executeActivity( + GreetingActivities::log, + KActivityOptions(startToCloseTimeout = 5.seconds), + "Processing started" +) +``` + +## Parameter Restrictions + +**Default parameter values are allowed for methods with 0 or 1 arguments.** For methods with 2+ arguments, defaults are not allowed. This is validated at worker registration time. + +```kotlin +// ✓ ALLOWED - 1 argument with default +suspend fun processOrder(priority: Int = 0): OrderResult + +// ✗ NOT ALLOWED - 2+ arguments with defaults +suspend fun processOrder(orderId: String, priority: Int = 0) // Error! + +// ✓ CORRECT for 2+ arguments - use a parameter object with optional fields +data class ProcessOrderParams( + val orderId: String, + val priority: Int? = null +) + +suspend fun processOrder(params: ProcessOrderParams) +``` + +**Rationale:** This aligns with Python, .NET, and Ruby SDKs which support defaults. For complex inputs with multiple parameters, the parameter object pattern avoids serialization ambiguity and cross-language issues. See [full discussion](../open-questions.md#default-parameter-values). + +## Type Safety + +The API uses `KFunction` reflection to extract method metadata and provides compile-time type checking: + +```kotlin +// Compile error! Wrong argument types +KWorkflow.executeActivity( + GreetingActivities::composeGreeting, + options, + 123, true // ✗ Type mismatch: expected String, String +) +``` + +## Parallel Execution + +Use standard `coroutineScope { async { } }` for concurrent execution: + +```kotlin +override suspend fun parallelGreetings(names: List): List = coroutineScope { + names.map { name -> + async { + KWorkflow.executeActivity( + GreetingActivities::composeGreeting, + KActivityOptions(startToCloseTimeout = 10.seconds), + "Hello", name + ) + } + }.awaitAll() // Standard kotlinx.coroutines.awaitAll +} + +// Multiple different activities in parallel +val (result1, result2) = coroutineScope { + val d1 = async { KWorkflow.executeActivity(Activities::operation1, options, arg1) } + val d2 = async { KWorkflow.executeActivity(Activities::operation2, options, arg2) } + awaitAll(d1, d2) +} +``` + +> **Note:** We use standard Kotlin `async` instead of a custom `startActivity` method. The workflow's deterministic dispatcher ensures correct replay behavior. + +## Java Activity Interoperability + +Method references work regardless of whether the activity is defined in Kotlin or Java: + +```kotlin +// Java activity interface works seamlessly +// public interface JavaPaymentActivities { +// PaymentResult processPayment(String orderId, BigDecimal amount); +// } + +val result: PaymentResult = KWorkflow.executeActivity( + JavaPaymentActivities::processPayment, + KActivityOptions(startToCloseTimeout = 2.minutes), + orderId, amount +) +``` + +## Activity Execution API + +The `KWorkflow` object provides type-safe overloads using `KFunction` types: + +```kotlin +object KWorkflow { + // 1 argument + suspend fun executeActivity( + activity: KFunction2, + options: KActivityOptions, + arg1: A1 + ): R + + // 2 arguments + suspend fun executeActivity( + activity: KFunction3, + options: KActivityOptions, + arg1: A1, arg2: A2 + ): R + + // ... up to 6 arguments + + // String-based overloads + suspend inline fun executeActivity( + activityName: String, + options: KActivityOptions, + vararg args: Any? + ): R +} +``` + +## Related + +- [Local Activities](./local-activities.md) - Short-lived local activities +- [Workflows](../workflows/README.md) - Calling activities from workflows + +--- + +## Open Questions (Decision Needed) + +### Interfaceless Activity Definition + +**Status:** Decision needed | [Full discussion](../open-questions.md#interfaceless-workflows-and-activities) + +Currently, activities require interface definitions: + +```kotlin +// Current approach - requires interface +@ActivityInterface +interface GreetingActivities { + suspend fun composeGreeting(greeting: String, name: String): String +} + +class GreetingActivitiesImpl : GreetingActivities { + override suspend fun composeGreeting(greeting: String, name: String) = "$greeting, $name!" +} +``` + +**Proposal:** Allow defining activities directly on implementation classes without interfaces, similar to Python SDK: + +```kotlin +// Proposed approach - no interface required +class GreetingActivities { + suspend fun composeGreeting(greeting: String, name: String) = "$greeting, $name!" +} + +// In workflow - call using method reference to impl class +val result = KWorkflow.executeActivity( + GreetingActivities::composeGreeting, + KActivityOptions(startToCloseTimeout = 30.seconds), + "Hello", "World" +) +``` + +**Benefits:** +- Reduces boilerplate (no separate interface file) +- More similar to Python SDK experience +- Kotlin-only feature, no Java SDK changes required + +**Trade-offs:** +- Different from Java SDK convention +- Activity name derived from method name (convention-based, respects `@ActivityMethod(name = "...")`) + +--- + +### Type-Safe Activity Arguments + +**Status:** Decision needed | [Full discussion](../open-questions.md#type-safe-activityworkflow-arguments) + +Three options for compile-time type-safe activity arguments: + +**Option A:** Keep current varargs (no type safety) + +**Option B:** Direct overloads (0-7 arguments each) +```kotlin +KWorkflow.executeActivity( + GreetingActivities::composeGreeting, + "Hello", "World", // Direct args - type checked + KActivityOptions(startToCloseTimeout = 30.seconds) +) +``` + +**Option C:** KArgs wrapper classes +```kotlin +KWorkflow.executeActivity( + GreetingActivities::composeGreeting, + kargs("Hello", "World"), // KArgs2 + KActivityOptions(startToCloseTimeout = 30.seconds) +) +``` + +See [full discussion](../open-questions.md#type-safe-activityworkflow-arguments) for comparison. + +--- + +**Next:** [Activity Implementation](./implementation.md) diff --git a/kotlin/activities/implementation.md b/kotlin/activities/implementation.md new file mode 100644 index 0000000..77b1b93 --- /dev/null +++ b/kotlin/activities/implementation.md @@ -0,0 +1,269 @@ +# Activity Implementation + +Activity interfaces can have both suspend and non-suspend methods. The worker handles both automatically. + +## Defining Activities + +```kotlin +@ActivityInterface +interface OrderActivities { + // Suspend method - uses coroutines + suspend fun chargePayment(order: Order): PaymentResult + + // Non-suspend method - runs on thread pool + fun validateOrder(order: Order): Boolean +} + +// Use @ActivityMethod only when customizing the activity name +@ActivityInterface +interface CustomOrderActivities { + @ActivityMethod(name = "charge-payment") + suspend fun chargePayment(order: Order): PaymentResult +} + +class OrderActivitiesImpl( + private val paymentService: PaymentService +) : OrderActivities { + + override suspend fun chargePayment(order: Order): PaymentResult { + // Full coroutine support - use withContext, async I/O, etc. + return paymentService.charge(order) + } + + override fun validateOrder(order: Order): Boolean { + return order.items.isNotEmpty() && order.total > 0 + } +} +``` + +## Calling Activities from Workflows + +```kotlin +// Kotlin workflows use method references +val isValid = KWorkflow.executeActivity( + OrderActivities::validateOrder, + KActivityOptions(startToCloseTimeout = 10.seconds), + order +) + +val payment = KWorkflow.executeActivity( + OrderActivities::chargePayment, + KActivityOptions(startToCloseTimeout = 30.seconds), + order +) +``` + +## Java Interoperability + +Java workflows call Kotlin activities using string-based activity names: + +```java +// Java workflow calling Kotlin activity +String result = Workflow.newActivityStub(ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(30)) + .build()) + .execute("chargePayment", PaymentResult.class, order); +``` + +This mirrors how Java workflows call Kotlin workflows - using string names for cross-language interop. + +## Registering Activities + +```kotlin +worker.registerActivitiesImplementations(OrderActivitiesImpl(paymentService)) +``` + +## Heartbeating + +For long-running activities, use heartbeating to report progress. **Heartbeat throws `CancellationException`** when the activity is cancelled, ensuring activities don't continue running and consuming worker slots. + +```kotlin +class LongRunningActivitiesImpl : LongRunningActivities { + override suspend fun processLargeFile(filePath: String): ProcessResult { + val context = KActivityContext.current() + val lines = File(filePath).readLines() + + lines.forEachIndexed { index, line -> + // Heartbeat throws CancellationException if activity is cancelled + context.heartbeat(index) + + // Process line... + processLine(line) + } + + return ProcessResult(linesProcessed = lines.size) + } +} +``` + +**Rationale:** Throwing on cancellation prevents activities from eating worker slots when developers forget to check cancellation. This aligns with Java SDK behavior and ensures consistent cancellation handling. + +## Heartbeat Details Recovery + +Retrieve heartbeat details from a previous failed attempt: + +```kotlin +override suspend fun resumableProcess(data: List): ProcessResult { + val ctx = KActivityContext.current() + + // Get progress from previous attempt if available + val startIndex = ctx.lastHeartbeatDetails() ?: 0 + + for (i in startIndex until data.size) { + ctx.heartbeat(i) + processItem(data[i]) + } + + return ProcessResult(success = true) +} +``` + +## Activity Cancellation + +Activity cancellation is delivered via `heartbeat()` - when an activity is cancelled, the next heartbeat call throws `CancellationException`. This works consistently for both suspend and non-suspend activities. + +### Cancellation via Heartbeat + +```kotlin +override suspend fun processItems(items: List): ProcessResult { + val context = KActivityContext.current() + for (item in items) { + // CancellationException thrown here if activity is cancelled + context.heartbeat() + processItem(item) + } + return ProcessResult(success = true) +} + +// With cleanup on cancellation +override suspend fun processWithCleanup(items: List): ProcessResult { + val context = KActivityContext.current() + return try { + for (item in items) { + context.heartbeat() + processItem(item) + } + ProcessResult(success = true) + } catch (e: CancellationException) { + // Cleanup on cancellation + cleanup() + throw e // Re-throw to complete as cancelled + } +} +``` + +### Non-Suspend Activities + +Non-suspend activities also receive cancellation via heartbeat: + +```kotlin +override fun processItemsBlocking(items: List): ProcessResult { + val context = KActivityContext.current() + for (item in items) { + // CancellationException thrown here if activity is cancelled + context.heartbeat() + processItem(item) + } + return ProcessResult(success = true) +} +``` + +> **TODO:** `KActivityContext.current().cancellationFuture()` will be added when cancellation can be delivered without requiring heartbeat calls (e.g., server-push cancellation). This will return a `CompletableFuture` for activities that need cancellation notification without heartbeating. + +## KActivityContext API + +`KActivityContext.current()` provides access to the activity execution context for both regular and local activities: + +```kotlin +// In activity implementation +val ctx = KActivityContext.current() + +// Get activity info (works for both regular and local activities) +val info = ctx.info +println("Activity ${info.activityType}, attempt ${info.attempt}") +println("Is local: ${info.isLocal}") + +// Heartbeat for long-running activities (no-op for local activities) +// Throws CancellationException if activity is cancelled +ctx.heartbeat(progressDetails) + +// Get last heartbeat details from previous attempt (null for local activities) +val previousProgress = ctx.lastHeartbeatDetails() + +// Regular activities only - throws UnsupportedOperationException for local activities +ctx.doNotCompleteOnReturn() +val taskToken = ctx.taskToken +``` + +## KActivityContext Interface + +```kotlin +interface KActivityContext { + /** Get the current activity context. */ + companion object { + fun current(): KActivityContext + } + + val info: KActivityInfo + + /** + * Send a heartbeat with optional progress details. + * Throws CancellationException if the activity has been cancelled. + * No-op for local activities. + */ + fun heartbeat(details: Any? = null) + + /** Get last heartbeat details from previous attempt. Null for local activities. */ + fun lastHeartbeatDetails(detailsClass: Class): T? + + /** Task token for async completion. Throws for local activities. */ + val taskToken: ByteArray + + /** Mark activity for manual completion. Throws for local activities. */ + fun doNotCompleteOnReturn() + + val isDoNotCompleteOnReturn: Boolean +} + +// Reified extension for easier Kotlin usage +inline fun KActivityContext.lastHeartbeatDetails(): T? +``` + +## KActivityInfo Interface + +```kotlin +interface KActivityInfo { + // Core identifiers + val activityId: String + val activityType: String + val workflowId: String + val runId: String + val namespace: String + + // Execution context + val taskQueue: String + val attempt: Int + val isLocal: Boolean + + // Timing information + val scheduledTime: Instant? + val startedTime: Instant? + val scheduleToCloseTimeout: Duration? + val startToCloseTimeout: Duration? + val heartbeatTimeout: Duration? + + // Task token for async completion (throws for local activities) + val taskToken: ByteArray +} +``` + +> **Note:** `ActivityCompletionClient` for async activity completion uses the same API as the Java SDK. Async completion is not supported for local activities. + +## Related + +- [Activity Definition](./definition.md) - Interface patterns +- [Worker Setup](../worker/setup.md) - Registering activities + +--- + +**Next:** [Local Activities](./local-activities.md) diff --git a/kotlin/activities/local-activities.md b/kotlin/activities/local-activities.md new file mode 100644 index 0000000..0dce121 --- /dev/null +++ b/kotlin/activities/local-activities.md @@ -0,0 +1,86 @@ +# Local Activities + +Local activities use the same stub-less pattern as regular activities. + +## Local Activity Execution + +```kotlin +@ActivityInterface +interface ValidationActivities { + suspend fun validate(input: String): Boolean + fun sanitize(input: String): String // Non-suspend also supported +} + +val isValid = KWorkflow.executeLocalActivity( + ValidationActivities::validate, + KLocalActivityOptions(startToCloseTimeout = 5.seconds), + input +) + +val sanitized = KWorkflow.executeLocalActivity( + ValidationActivities::sanitize, + KLocalActivityOptions(startToCloseTimeout = 1.seconds), + input +) +``` + +## KWorkflow Local Activity Methods + +```kotlin +object KWorkflow { + /** + * Execute a local activity with type-safe method reference. + */ + suspend fun executeLocalActivity( + activity: KFunction2, + options: KLocalActivityOptions, + arg1: A1 + ): R + + suspend fun executeLocalActivity( + activity: KFunction1, + options: KLocalActivityOptions + ): R + + // ... up to 6 arguments +} +``` + +## KLocalActivityOptions + +```kotlin +/** + * Kotlin-native local activity options. + */ +data class KLocalActivityOptions( + val startToCloseTimeout: Duration? = null, + val scheduleToCloseTimeout: Duration? = null, + val scheduleToStartTimeout: Duration? = null, + val localRetryThreshold: Duration? = null, + val retryOptions: KRetryOptions? = null, + val doNotIncludeArgumentsIntoMarker: Boolean = false, + // Experimental + @Experimental val summary: String? = null +) +``` + +## KActivityContext in Local Activities + +`KActivityContext.current()` is available in local activities with limited functionality: + +| Feature | Local Activity Behavior | +|---------|------------------------| +| `ctx.info` | Works (`info.isLocal` returns `true`) | +| `ctx.heartbeat()` | No-op (ignored) | +| `ctx.lastHeartbeatDetails()` | Returns `null` | +| `ctx.taskToken` | Throws `UnsupportedOperationException` | +| `ctx.doNotCompleteOnReturn()` | Throws `UnsupportedOperationException` | + +## Related + +- [Activity Definition](./definition.md) - Interface patterns +- [Activity Implementation](./implementation.md) - Full implementation details + +--- + +**Next:** [Client](../client/README.md) diff --git a/kotlin/api-parity.md b/kotlin/api-parity.md new file mode 100644 index 0000000..bf39627 --- /dev/null +++ b/kotlin/api-parity.md @@ -0,0 +1,220 @@ +# Java SDK API Parity + +This document describes the intentional differences between Java and Kotlin SDK APIs, and identifies remaining gaps. + +## APIs Not Needed in Kotlin + +The following Java SDK APIs are **not needed** in the Kotlin SDK due to language differences: + +| Java SDK API | Reason Not Needed | +|--------------|-------------------| +| `Workflow.sleep(Duration)` | Use `kotlinx.coroutines.delay()` or `KWorkflow.delay()` with options | +| `Workflow.newTimer(Duration)` | Use `async { delay(duration) }` or `async { KWorkflow.delay(duration, options) }` for racing timers | +| `Workflow.wrap(Exception)` | Kotlin has no checked exceptions - not needed | +| `Activity.wrap(Throwable)` | Kotlin has no checked exceptions - not needed | +| `Workflow.newCancellationScope(...)` | Use Kotlin's `coroutineScope { }` with structured concurrency | +| `Workflow.newDetachedCancellationScope(...)` | Use `supervisorScope { }` or launch in parent scope | +| `Workflow.newWorkflowLock()` | Not needed - cooperative coroutines don't have true concurrency | +| `Workflow.newWorkflowSemaphore(...)` | Use structured concurrency patterns (e.g., `chunked().map { async }.awaitAll()`) | +| `Workflow.newQueue(...)` / `newWorkflowQueue(...)` | Use `mutableListOf` + `awaitCondition` - no concurrent access in suspend model | +| `Workflow.newPromise()` / `newFailedPromise(...)` | Use `CompletableDeferred` from kotlinx.coroutines | +| `Workflow.setDefaultActivityOptions(...)` | Pass `KActivityOptions` per call, or define shared `val defaultOptions` | +| `Workflow.setActivityOptions(...)` | Pass options per call | +| `Workflow.applyActivityOptions(...)` | Pass options per call | +| `Workflow.setDefaultLocalActivityOptions(...)` | Pass `KLocalActivityOptions` per call | +| `Workflow.applyLocalActivityOptions(...)` | Pass options per call | + +## Activity API Design Decisions + +### Infallible heartbeat() API + +The Kotlin SDK provides a single `heartbeat()` method on `KActivityExecutionContext` that is **infallible** - it never throws exceptions: + +```kotlin +context.heartbeat(progressDetails) // Never throws +``` + +**Rationale:** Heartbeat is a local operation that records progress. The actual network communication happens asynchronously in the background. Making heartbeat infallible simplifies activity code - cancellation is handled through dedicated cancellation mechanisms (see below). + +### Activity Cancellation + +Cancellation works differently for suspend and non-suspend activities: + +**Suspend activities:** Use standard Kotlin coroutine cancellation. `CancellationException` is thrown at suspension points when the activity is cancelled. + +```kotlin +override suspend fun processItems(items: List): ProcessResult { + for (item in items) { + processItem(item) // CancellationException thrown here if cancelled + } + return ProcessResult(success = true) +} +``` + +**Non-suspend activities:** Use `KActivity.cancellationFuture()` which returns a `CompletableFuture`: + +```kotlin +override fun processItemsBlocking(items: List): ProcessResult { + val cancellationFuture = KActivity.cancellationFuture() + + for (item in items) { + if (cancellationFuture.isDone) { + val details = cancellationFuture.get() + throw CancellationException("Cancelled: ${details.message}") + } + processItem(item) + } + return ProcessResult(success = true) +} +``` + +### Both Sync and Suspend Activities Supported + +The Kotlin SDK supports both regular and suspend activity methods: + +```kotlin +@ActivityInterface +interface OrderActivities { + fun validate(order: Order): Boolean // Sync - runs on thread pool + suspend fun fetchExternal(id: String): Data // Suspend - runs on coroutine +} +``` + +**Rationale:** This matches other Kotlin frameworks (Spring, Micronaut, gRPC-Kotlin) that detect method signatures and handle them appropriately. It reduces migration friction from Java SDK and gives developers choice. + +## Cancellation Support + +Kotlin coroutine cancellation is fully integrated with Temporal workflow cancellation: + +- Workflow cancellation triggers `coroutineScope.cancel(CancellationException(...))` +- Cancellation propagates to all child coroutines (activities, timers, child workflows) +- Activities use `suspendCancellableCoroutine` with `invokeOnCancellation` to cancel via Temporal +- Use standard Kotlin patterns: `try/finally`, `use { }`, `invokeOnCompletion` + +## Implemented APIs (Java SDK Parity) + +The following Java SDK workflow APIs have Kotlin equivalents in `KWorkflow`: + +### Search Attributes & Memo + +| Java SDK API | Kotlin SDK | +|--------------|------------| +| `Workflow.getTypedSearchAttributes()` | `KWorkflow.searchAttributes` | +| `Workflow.upsertTypedSearchAttributes(...)` | `KWorkflow.upsertSearchAttributes()` | +| `Workflow.getMemo(key, class)` | `KWorkflow.memo` | +| `Workflow.upsertMemo(...)` | `KWorkflow.upsertMemo()` | + +### Workflow State & Context + +| Java SDK API | Kotlin SDK | +|--------------|------------| +| `Workflow.getLastCompletionResult(class)` | `KWorkflow.lastCompletionResult()` | +| `Workflow.getPreviousRunFailure()` | `KWorkflow.previousRunFailure` | +| `Workflow.isReplaying()` | `KWorkflow.isReplaying` | +| `Workflow.getCurrentUpdateInfo()` | `KWorkflow.currentUpdateInfo` | +| `Workflow.isEveryHandlerFinished()` | `KWorkflow.isEveryHandlerFinished` | +| `Workflow.setCurrentDetails(...)` | `KWorkflow.currentDetails = ...` | +| `Workflow.getCurrentDetails()` | `KWorkflow.currentDetails` | +| `Workflow.getMetricsScope()` | `KWorkflow.metricsScope` | + +### Timers + +| Java SDK API | Kotlin SDK | +|--------------|------------| +| `Workflow.sleep(...)` | `kotlinx.coroutines.delay()` - standard Kotlin, or `KWorkflow.delay()` | +| N/A | `KWorkflow.delay(duration)` - simple delay | +| N/A | `KWorkflow.delay(duration, summary)` - with summary string | + +**KWorkflow.delay overloads:** + +```kotlin +object KWorkflow { + /** Simple delay - equivalent to kotlinx.coroutines.delay() */ + suspend fun delay(duration: Duration) + + /** Delay with summary for observability */ + suspend fun delay(duration: Duration, summary: String) +} + +// Usage examples +KWorkflow.delay(5.minutes) +KWorkflow.delay(1.hours, "Waiting for approval timeout") +``` + +### Side Effects & Utilities + +| Java SDK API | Kotlin SDK | +|--------------|------------| +| `Workflow.sideEffect(...)` | `KWorkflow.sideEffect()` | +| `Workflow.mutableSideEffect(...)` | `KWorkflow.mutableSideEffect()` | +| `Workflow.getVersion(...)` | `KWorkflow.version()` | +| `Workflow.retry(...)` | `KWorkflow.retry()` | +| `Workflow.randomUUID()` | `KWorkflow.randomUUID()` | +| `Workflow.newRandom()` | `KWorkflow.newRandom()` | + +### Dynamic Handler Registration + +| Java SDK API | Kotlin SDK | +|--------------|------------| +| `Workflow.registerListener(DynamicSignalHandler)` | `KWorkflow.registerDynamicSignalHandler()` | +| `Workflow.registerListener(DynamicQueryHandler)` | `KWorkflow.registerDynamicQueryHandler()` | +| `Workflow.registerListener(DynamicUpdateHandler)` | `KWorkflow.registerDynamicUpdateHandler()` | +| N/A (new in Kotlin SDK) | `KWorkflow.registerSignalHandler()` | +| N/A (new in Kotlin SDK) | `KWorkflow.registerQueryHandler()` | +| N/A (new in Kotlin SDK) | `KWorkflow.registerUpdateHandler()` | +| N/A (new in Kotlin SDK) | `KWorkflow.registerDynamicUpdateValidator()` | + +## APIs Identical to Java SDK + +The following areas use the same API and behavior as the Java SDK: + +| Area | Notes | +|------|-------| +| Error Handling | `ApplicationFailure`, exception types (`ActivityFailure`, `ChildWorkflowFailure`, `TimeoutFailure`, etc.), retry behavior | +| Workflow Info | `KWorkflow.info` provides property-style access to Java `WorkflowInfo` (same properties: `workflowId`, `runId`, `parentWorkflowId`, `attempt`, `taskQueue`, etc.) | + +## Remaining Gaps + +| Java SDK API | Status | +|--------------|--------| +| `Workflow.newNexusServiceStub(...)` | Nexus support - deferred to separate project | +| `Workflow.startNexusOperation(...)` | Nexus support - deferred to separate project | +| `Workflow.getInstance()` | Advanced use case - low priority | + +## KWorkflowInfo + +`KWorkflow.info` returns `KWorkflowInfo`, a property-style wrapper around Java's `WorkflowInfo`: + +| Java WorkflowInfo | Kotlin SDK | +|-------------------|------------| +| `getWorkflowId()` | `KWorkflowInfo.workflowId` | +| `getRunId()` | `KWorkflowInfo.runId` | +| `getWorkflowType()` | `KWorkflowInfo.workflowType` | +| `getTaskQueue()` | `KWorkflowInfo.taskQueue` | +| `getNamespace()` | `KWorkflowInfo.namespace` | +| `getAttempt()` | `KWorkflowInfo.attempt` | +| `getParentWorkflowId()` | `KWorkflowInfo.parentWorkflowId: String?` | +| `getParentRunId()` | `KWorkflowInfo.parentRunId: String?` | +| `getContinuedExecutionRunId()` | `KWorkflowInfo.continuedExecutionRunId: String?` | +| `getCronSchedule()` | `KWorkflowInfo.cronSchedule: String?` | +| `getSearchAttributes()` | `KWorkflowInfo.searchAttributes` | +| `getHistoryLength()` | `KWorkflowInfo.historyLength` | +| `isContinueAsNewSuggested()` | `KWorkflowInfo.isContinueAsNewSuggested` | +| `getFirstExecutionRunId()` | `KWorkflowInfo.firstExecutionRunId` | +| `getOriginalExecutionRunId()` | `KWorkflowInfo.originalExecutionRunId` | +| `getCurrentBuildId()` | `KWorkflowInfo.currentBuildId: String?` | +| `getPriority()` | `@Experimental KWorkflowInfo.priority` | + +## KActivityInfo + +| Java ActivityInfo | Kotlin SDK | +|-------------------|------------| +| `getWorkflowType()` | `KActivityInfo.workflowType` | +| `getCurrentAttemptScheduledTimestamp()` | `KActivityInfo.currentAttemptScheduledTimestamp` | +| `getRetryOptions()` | `KActivityInfo.retryOptions` | + +## Related + +- [Migration Guide](./migration.md) - Practical migration steps +- [Kotlin Idioms](./kotlin-idioms.md) - Kotlin-specific patterns +- [README](./README.md) - Back to documentation home diff --git a/kotlin/client/README.md b/kotlin/client/README.md new file mode 100644 index 0000000..1a4608c --- /dev/null +++ b/kotlin/client/README.md @@ -0,0 +1,90 @@ +# Client API + +This section covers the Kotlin client API for interacting with Temporal. + +## Overview + +The Kotlin SDK provides `KClient`, a unified client (like Python's `Client` and .NET's `TemporalClient`) with suspend functions and type-safe APIs for workflows, schedules, and async activity completion. + +## Documents + +| Document | Description | +|----------|-------------| +| [Client](./workflow-client.md) | KClient, starting workflows, schedules | +| [Workflow Handles](./workflow-handle.md) | Typed/Untyped handles, signals, queries, results | +| [Advanced Operations](./advanced.md) | SignalWithStart, UpdateWithStart | + +## Quick Reference + +### Creating a Client + +```kotlin +val client = KClient.connect( + KClientOptions( + target = "localhost:7233", + namespace = "default" + ) +) +``` + +### Starting Workflows + +```kotlin +// Execute and wait for result +val result = client.executeWorkflow( + GreetingWorkflow::getGreeting, + KWorkflowOptions( + workflowId = "greeting-123", + taskQueue = "greetings" + ), + "Temporal" +) + +// Start async and get handle +val handle = client.startWorkflow( + GreetingWorkflow::getGreeting, + KWorkflowOptions( + workflowId = "greeting-123", + taskQueue = "greetings" + ), + "Temporal" +) +val result = handle.result() +``` + +### Interacting with Workflows + +```kotlin +val handle = client.workflowHandle("order-123") + +// Signal +handle.signal(OrderWorkflow::updatePriority, Priority.HIGH) + +// Query +val status = handle.query(OrderWorkflow::status) + +// Update +val result = handle.executeUpdate(OrderWorkflow::addItem, newItem) + +// Cancel +handle.cancel() +``` + +### Key Patterns + +| Pattern | API | +|---------|-----| +| Execute workflow | `client.executeWorkflow(Interface::method, options, args)` | +| Start workflow | `client.startWorkflow(Interface::method, options, args)` | +| Get handle by ID | `client.workflowHandle(workflowId)` | +| Signal with start | `client.signalWithStart(...)` | +| Update with start | `client.executeUpdateWithStart(...)` | + +## Related + +- [Workflow Handles](./workflow-handle.md) - Interacting with running workflows +- [Advanced Operations](./advanced.md) - Atomic operations + +--- + +**Next:** [Workflow Client](./workflow-client.md) diff --git a/kotlin/client/advanced.md b/kotlin/client/advanced.md new file mode 100644 index 0000000..458ff62 --- /dev/null +++ b/kotlin/client/advanced.md @@ -0,0 +1,181 @@ +# Advanced Client Operations + +Both SignalWithStart and UpdateWithStart use `withStartWorkflowOperation` to create a handle that becomes usable after the atomic operation completes. + +## SignalWithStart + +Atomically start a workflow and send a signal. If the workflow already exists, only the signal is sent: + +```kotlin +// Create handle (not yet started) +val handle = client.withStartWorkflowOperation( + OrderWorkflow::processOrder, + KWorkflowOptions( + workflowId = "order-123", + taskQueue = "orders" + ), + order +) + +// Atomically start workflow and send signal +client.signalWithStart(handle, OrderWorkflow::updatePriority, Priority.HIGH) + +// Handle is now usable for queries/signals/result +val status = handle.query(OrderWorkflow::status) +val result = handle.result() // Type inferred as OrderResult +``` + +## UpdateWithStart + +Atomically start a workflow and send an update. Behavior depends on `workflowIdConflictPolicy`: +- `USE_EXISTING`: sends update to existing workflow +- `FAIL`: throws exception if workflow already exists + +### KUpdateWithStartOptions + +```kotlin +/** + * Options for update-with-start operations. + * Note: waitForStage is required for startUpdateWithStart (no default value). + */ +data class KUpdateWithStartOptions( + /** Stage to wait for before returning (required - no default) */ + val waitForStage: WorkflowUpdateStage, + + /** Optional update ID for idempotency */ + val updateId: String? = null +) +``` + +### Execute and Wait for Completion + +```kotlin +// Create handle (not yet started) +val handle = client.withStartWorkflowOperation( + OrderWorkflow::processOrder, + KWorkflowOptions( + workflowId = "order-123", + taskQueue = "orders", + workflowIdConflictPolicy = WorkflowIdConflictPolicy.USE_EXISTING + ), + order +) + +// Execute update with start (waits for update completion) +// Args before options +val updateResult: Boolean = client.executeUpdateWithStart( + handle, + OrderWorkflow::addItem, + newItem, // arg before options + KUpdateWithStartOptions(waitForStage = WorkflowUpdateStage.COMPLETED) +) + +// Handle is now usable +val workflowResult: OrderResult = handle.result() +``` + +### Start Async (Don't Wait for Completion) + +```kotlin +// Create handle (not yet started) +val handle = client.withStartWorkflowOperation( + OrderWorkflow::processOrder, + KWorkflowOptions( + workflowId = "order-456", + taskQueue = "orders", + workflowIdConflictPolicy = WorkflowIdConflictPolicy.FAIL + ), + order +) + +// Start update and return immediately after it's accepted +// waitForStage is required - no default +val updateHandle: KUpdateHandle = client.startUpdateWithStart( + handle, + OrderWorkflow::addItem, + newItem, // arg before options + KUpdateWithStartOptions(waitForStage = WorkflowUpdateStage.ACCEPTED) +) + +// Handle is now usable, get workflow result +val workflowResult = handle.result() + +// Get update result when needed +val updateResult = updateHandle.result() +``` + +## KWorkflowClient API for Advanced Operations + +```kotlin +class KWorkflowClient { + /** + * Create a workflow handle for use with signal-with-start or update-with-start. + * The handle is not usable until signalWithStart or an update-with-start method is called. + */ + fun withStartWorkflowOperation( + workflow: KSuspendFunction1, + options: KWorkflowOptions + ): KWorkflowHandleWithResult + + fun withStartWorkflowOperation( + workflow: KSuspendFunction2, + options: KWorkflowOptions, + arg1: A1 + ): KWorkflowHandleWithResult + + /** + * Atomically start a workflow and send a signal. + * If the workflow already exists, only the signal is sent. + * After this call, the handle becomes usable. + */ + suspend fun signalWithStart( + handle: KWorkflowHandleWithResult, + signal: KSuspendFunction2, + signalArg: SA1 + ) + + /** + * Atomically start a workflow and execute an update, waiting for completion. + * After this call, the handle becomes usable. + */ + suspend fun executeUpdateWithStart( + handle: KWorkflowHandleWithResult, + update: KSuspendFunction1, + options: KUpdateWithStartOptions = KUpdateWithStartOptions() + ): UR + + suspend fun executeUpdateWithStart( + handle: KWorkflowHandleWithResult, + update: KSuspendFunction2, + updateArg: UA1, + options: KUpdateWithStartOptions = KUpdateWithStartOptions() + ): UR + + /** + * Atomically start a workflow and send an update, returning immediately after + * the update reaches the specified wait stage. + * After this call, the handle becomes usable. + */ + suspend fun startUpdateWithStart( + handle: KWorkflowHandleWithResult, + update: KSuspendFunction1, + options: KUpdateWithStartOptions // waitForStage required - no default + ): KUpdateHandle + + suspend fun startUpdateWithStart( + handle: KWorkflowHandleWithResult, + update: KSuspendFunction2, + updateArg: UA1, + options: KUpdateWithStartOptions + ): KUpdateHandle +} +``` + +## Related + +- [Workflow Client](./workflow-client.md) - Basic client operations +- [Workflow Handles](./workflow-handle.md) - Interacting with workflows + +--- + +**Next:** [Worker](../worker/README.md) diff --git a/kotlin/client/workflow-client.md b/kotlin/client/workflow-client.md new file mode 100644 index 0000000..b928ba4 --- /dev/null +++ b/kotlin/client/workflow-client.md @@ -0,0 +1,188 @@ +# Client + +## Creating a Client + +Use `KClient.connect()` to create a client (unified client like Python/.NET SDKs): + +```kotlin +// Connect to Temporal (like Python/.NET SDKs) +val client = KClient.connect( + KClientOptions( + target = "localhost:7233", + namespace = "default" + ) +) + +// Access raw gRPC services when needed +val workflowService = client.workflowService + +// For blocking calls from non-suspend contexts, use runBlocking +val result = runBlocking { + client.executeWorkflow(MyWorkflow::process, options, input) +} +``` + +## KClient + +`KClient` is a unified client (like Python's `Client` and .NET's `TemporalClient`) providing Kotlin-specific APIs with suspend functions for workflows, schedules, and async activity completion: + +```kotlin +/** + * Unified Kotlin client providing suspend functions and type-safe APIs + * for workflows, schedules, and async activity completion. + */ +class KClient private constructor(...) { + companion object { + /** + * Connect to Temporal service and create a client. + */ + suspend fun connect(options: KClientOptions): KClient + } + + /** The underlying WorkflowServiceStubs for advanced use cases */ + val workflowService: WorkflowServiceStubs + + // ==================== Workflow Operations ==================== + + /** + * Start a workflow and return a handle for interaction. + * Does not wait for the workflow to complete. + */ + suspend fun startWorkflow( + workflow: KSuspendFunction1, + options: KWorkflowOptions + ): KWorkflowHandleWithResult + + suspend fun startWorkflow( + workflow: KSuspendFunction2, + options: KWorkflowOptions, + arg: A1 + ): KWorkflowHandleWithResult + + // Overloads for 2+ arguments using kargs()... + + /** + * Start a workflow and wait for its result. + * Suspends until the workflow completes. + */ + suspend fun executeWorkflow( + workflow: KSuspendFunction1, + options: KWorkflowOptions + ): R + + suspend fun executeWorkflow( + workflow: KSuspendFunction2, + options: KWorkflowOptions, + arg: A1 + ): R + + // Overloads for 2+ arguments using kargs()... + + /** + * Get a typed handle for an existing workflow by ID. + */ + inline fun workflowHandle(workflowId: String): KWorkflowHandle + inline fun workflowHandle(workflowId: String, runId: String): KWorkflowHandle + + /** + * Get a typed handle with known result type for an existing workflow by ID. + */ + inline fun workflowHandle(workflowId: String): KWorkflowHandleWithResult + inline fun workflowHandle(workflowId: String, runId: String): KWorkflowHandleWithResult + + /** + * Get an untyped handle for an existing workflow by ID. + */ + fun untypedWorkflowHandle(workflowId: String): KWorkflowHandleUntyped + fun untypedWorkflowHandle(workflowId: String, runId: String): KWorkflowHandleUntyped + + // ==================== Schedule Operations ==================== + + /** + * Create a new schedule. + */ + suspend fun createSchedule( + scheduleId: String, + schedule: KSchedule, + options: KScheduleOptions = KScheduleOptions() + ): KScheduleHandle + + /** + * Get a handle to an existing schedule. + */ + fun scheduleHandle(scheduleId: String): KScheduleHandle + + /** + * List all schedules. + */ + fun listSchedules(): Flow + + // ==================== Async Activity Completion ==================== + + /** + * Get a handle for async activity completion using task token. + */ + fun activityCompletionHandle(taskToken: ByteArray): KActivityCompletionHandle +} +``` + +## Starting Workflows + +```kotlin +// Execute workflow and wait for result (suspend function) +val result = client.executeWorkflow( + GreetingWorkflow::getGreeting, + KWorkflowOptions( + workflowId = "greeting-123", + taskQueue = "greeting-queue", + workflowExecutionTimeout = 1.hours + ), + "Temporal" +) + +// Or start async and get handle +val handle = client.startWorkflow( + GreetingWorkflow::getGreeting, + KWorkflowOptions( + workflowId = "greeting-123", + taskQueue = "greeting-queue" + ), + "Temporal" +) +val result = handle.result() // Type inferred as String from method reference + +// Get handle for existing workflow +val existingHandle = client.workflowHandle("greeting-123") +val result = existingHandle.result() // Must specify result type + +// Or with result type known +val typedHandle = client.workflowHandle("greeting-123") +val result = typedHandle.result() // Result type already known +``` + +## KClientOptions + +```kotlin +data class KClientOptions( + val target: String = "localhost:7233", + val namespace: String = "default", + val identity: String? = null, + val dataConverter: DataConverter? = null, + val interceptors: List = emptyList(), + // ... other options +) +``` + +## KWorkflowOptions + +See [KOptions](../configuration/koptions.md#kworkflowoptions) for the full `KWorkflowOptions` reference. + +## Related + +- [Advanced Operations](./advanced.md) - SignalWithStart, UpdateWithStart +- [KOptions](../configuration/koptions.md) - KWorkflowOptions reference +- [Schedules](./schedules.md) - Schedule operations + +--- + +**Next:** [Workflow Handles](./workflow-handle.md) diff --git a/kotlin/client/workflow-handle.md b/kotlin/client/workflow-handle.md new file mode 100644 index 0000000..7cd333a --- /dev/null +++ b/kotlin/client/workflow-handle.md @@ -0,0 +1,355 @@ +# Workflow Handles + +For interacting with existing workflows (signals, queries, results, cancellation), use a typed or untyped handle. + +## Handle Type Hierarchy + +```kotlin +KWorkflowHandleUntyped // Untyped base +KWorkflowHandle : KWorkflowHandleUntyped // Typed workflow, untyped result +KWorkflowHandleWithResult : KWorkflowHandle // Fully typed +``` + +## Update Options + +```kotlin +/** + * Options for executing updates. + */ +data class KUpdateOptions( + /** Optional update ID for idempotency */ + val updateId: String? = null +) + +/** + * Options for starting updates (not waiting for completion). + * Note: waitForStage is required with no default value. + */ +data class KStartUpdateOptions( + /** Stage to wait for before returning (required) */ + val waitForStage: WorkflowUpdateStage, // No default - must be specified + + /** Optional update ID for idempotency */ + val updateId: String? = null +) +``` + +## Typed Handles + +```kotlin +// Get typed handle for existing workflow by ID +val handle = client.workflowHandle("order-123") + +// Send signal - method reference provides type safety +handle.signal(OrderWorkflow::updatePriority, Priority.HIGH) + +// Query - method reference with compile-time type checking +val status = handle.query(OrderWorkflow::status) +val count = handle.query(OrderWorkflow::getItemCount) + +// Get result (suspends until workflow completes) - must specify type +val result = handle.result() + +// Or get handle with known result type +val typedHandle = client.workflowHandle("order-123") +val result = typedHandle.result() // Type already known + +// Updates - execute and wait for result +val updateResult = handle.executeUpdate( + OrderWorkflow::addItem, + newItem, + KUpdateOptions(updateId = "add-item-1") // Options always last +) + +// Updates - start and get handle (don't wait for completion) +val updateHandle = handle.startUpdate( + OrderWorkflow::addItem, + newItem, + KStartUpdateOptions(waitForStage = WorkflowUpdateStage.ACCEPTED) // waitForStage required +) +// ... do other work ... +val result = updateHandle.result() // Wait for result when needed + +// Cancel or terminate +handle.cancel() +handle.terminate("No longer needed") + +// Workflow metadata +val description = handle.describe() +println("Workflow ID: ${handle.workflowId}, Run ID: ${handle.runId}") +println("Status: ${description.status}") +``` + +## Architectural Note: Classes vs Interfaces + +All handle types (`KWorkflowHandleUntyped`, `KWorkflowHandle`, `KWorkflowHandleWithResult`, `KUpdateHandle`, `KChildWorkflowHandle`) are **classes** rather than interfaces. This design choice enables: + +1. **Reified type parameters** - `inline fun ` methods directly on handle types +2. **Better IDE discoverability** - Methods appear directly in autocomplete +3. **No extension function workarounds** - No need for separate extension functions for reified generics +4. **Full testing support** - mockk and other frameworks can mock classes + +## KWorkflowHandleUntyped API + +```kotlin +// Base untyped handle - returned by untypedWorkflowHandle(id) +open class KWorkflowHandleUntyped( + val workflowId: String, + val runId: String?, + val execution: WorkflowExecution, + // ... internal state +) { + // Result - requires explicit type since we don't know it + suspend fun result(resultClass: Class): R + inline suspend fun result(): R // Reified version + + // Signals by name + suspend fun signal(signalName: String, vararg args: Any?) + + // Queries by name (suspend for network I/O) + suspend fun query(queryName: String, resultClass: Class, vararg args: Any?): R + inline suspend fun query(queryName: String, vararg args: Any?): R // Reified version + + // Updates by name + suspend fun executeUpdate( + updateName: String, + resultClass: Class, + vararg args: Any?, + options: KUpdateOptions = KUpdateOptions() + ): R + inline suspend fun executeUpdate( + updateName: String, + vararg args: Any?, + options: KUpdateOptions = KUpdateOptions() + ): R + + suspend fun startUpdate( + updateName: String, + resultClass: Class, + vararg args: Any?, + options: KStartUpdateOptions // waitForStage is required - no default + ): KUpdateHandle + inline suspend fun startUpdate( + updateName: String, + vararg args: Any?, + options: KStartUpdateOptions + ): KUpdateHandle + + // Get handle for existing update by ID + fun updateHandle(updateId: String, resultClass: Class): KUpdateHandle + inline fun updateHandle(updateId: String): KUpdateHandle // Reified version + + // Lifecycle + suspend fun cancel() + suspend fun terminate(reason: String? = null) + suspend fun describe(): KWorkflowExecutionDescription + + // Java SDK interop + fun toStub(): WorkflowStub +} +``` + +## KWorkflowHandle API + +```kotlin +// Typed handle - returned by workflowHandle(id) +// Result type is unknown, must specify when calling result() +open class KWorkflowHandle( + workflowId: String, + runId: String?, + execution: WorkflowExecution, + // ... internal state +) : KWorkflowHandleUntyped(workflowId, runId, execution) { + + // Signals - type-safe method references + // Note: Signal handlers can be either suspend or non-suspend functions + suspend fun signal(method: KFunction1) + suspend fun signal(method: KFunction2, arg: A1) + suspend fun signal(method: KFunction3, arg1: A1, arg2: A2) + + // Queries - type-safe method references (suspend for network I/O) + suspend fun query(method: KFunction1): R + suspend fun query(method: KFunction2, arg: A1): R + + // Updates - execute and wait for result (options always last) + suspend fun executeUpdate( + method: KSuspendFunction1, + options: KUpdateOptions = KUpdateOptions() + ): R + suspend fun executeUpdate( + method: KSuspendFunction2, + arg: A1, + options: KUpdateOptions = KUpdateOptions() + ): R + // ... up to 6 arguments + + // Updates - start and return handle (waitForStage required in options) + suspend fun startUpdate( + method: KSuspendFunction1, + options: KStartUpdateOptions // waitForStage required - no default + ): KUpdateHandle + suspend fun startUpdate( + method: KSuspendFunction2, + arg: A1, + options: KStartUpdateOptions + ): KUpdateHandle + // ... up to 6 arguments +} +``` + +**Note on query methods:** Although queries are synchronous within the workflow, client-side query calls involve network I/O to the Temporal service, which is why they are `suspend` functions following idiomatic Kotlin patterns. + +## KWorkflowHandleWithResult + +Extended handle returned by `startWorkflow()` or `workflowHandle()` - result type R is known: + +```kotlin +class KWorkflowHandleWithResult( + workflowId: String, + runId: String?, + execution: WorkflowExecution, + // ... internal state +) : KWorkflowHandle(workflowId, runId, execution) { + // Result type is known - no type parameter needed + suspend fun result(): R + suspend fun result(timeout: java.time.Duration): R +} +``` + +**How result type is captured:** + +```kotlin +// startWorkflow captures result type from method reference +suspend fun startWorkflow( + workflow: KSuspendFunction2, // R is captured here + options: KWorkflowOptions, + arg: A1 +): KWorkflowHandleWithResult // R is preserved in return type + +// Usage - result type is inferred +val handle = client.startWorkflow( + OrderWorkflow::processOrder, // KSuspendFunction2 + options, + order +) +val result: OrderResult = handle.result() // No type parameter needed! + +// workflowHandle with one type param doesn't know result type +val existingHandle = client.workflowHandle(workflowId) +val result = existingHandle.result() // Must specify type + +// workflowHandle with two type params knows result type +val typedHandle = client.workflowHandle(workflowId) +val result = typedHandle.result() // Type already known +``` + +## KUpdateHandle + +```kotlin +class KUpdateHandle( + val updateId: String, + val execution: WorkflowExecution, + // ... internal state +) { + suspend fun result(): R + suspend fun result(timeout: java.time.Duration): R +} +``` + +## KWorkflowExecutionInfo and KWorkflowExecutionDescription + +Base class `KWorkflowExecutionInfo` is returned by `listWorkflows()`. Extended class `KWorkflowExecutionDescription` is returned by `describe()`: + +```kotlin +// Base class returned by listWorkflows() +open class KWorkflowExecutionInfo( + val execution: WorkflowExecution, + val workflowType: String, + val taskQueue: String, + val startTime: Instant, + val status: WorkflowExecutionStatus + // ... lighter set of fields +) + +// Extended class returned by describe() +class KWorkflowExecutionDescription( + execution: WorkflowExecution, + workflowType: String, + taskQueue: String, + startTime: Instant, + status: WorkflowExecutionStatus, + // Additional fields from describe() + val executionTime: Instant, + val closeTime: Instant?, + val historyLength: Long, + val parentNamespace: String?, + val parentExecution: WorkflowExecution?, + val rootExecution: WorkflowExecution?, + val firstRunId: String?, + val executionDuration: Duration?, + val searchAttributes: SearchAttributes, + // ... additional detail fields +) : KWorkflowExecutionInfo(execution, workflowType, taskQueue, startTime, status) { + + // Reified memo access + inline fun memo(key: String): T? + inline fun memo(key: String, genericType: Type): T? + + // Experimental properties + @Experimental val staticSummary: String? + @Experimental val staticDetails: String? + + // Raw response for advanced use cases + val rawDescription: DescribeWorkflowExecutionResponse +} +``` + +**Usage:** +```kotlin +// From describe() - full details +val description = handle.describe() +println("Type: ${description.workflowType}") +println("Status: ${description.status}") +val config: MyConfig? = description.memo("config") + +// From listWorkflows() - lighter weight +client.listWorkflows("WorkflowType = 'OrderWorkflow'").collect { info -> + println("${info.workflowType}: ${info.status}") +} +``` + +## Untyped Handles + +For cases where you don't know the workflow type at compile time, use `KWorkflowHandleUntyped` (see API definition above): + +```kotlin +// Untyped handle - signal/query by string name +val untypedHandle = client.untypedWorkflowHandle("order-123") + +// Operations use string names instead of method references +untypedHandle.signal("updatePriority", Priority.HIGH) +val status = untypedHandle.query("status") +val result = untypedHandle.result() + +// Updates by name - options always last, waitForStage required for startUpdate +val updateResult = untypedHandle.executeUpdate("addItem", newItem) +val updateHandle = untypedHandle.startUpdate( + "addItem", + newItem, + options = KStartUpdateOptions(waitForStage = WorkflowUpdateStage.ACCEPTED) +) + +// Cancel/terminate work the same +untypedHandle.cancel() +``` + +This pattern matches Python SDK's `WorkflowHandle` with the same method names (`signal`, `query`, `result`, `cancel`, `terminate`, `execute_update`). + +## Related + +- [Workflow Client](./workflow-client.md) - Creating clients +- [Signals, Queries & Updates](../workflows/signals-queries.md) - Handler definitions + +--- + +**Next:** [Advanced Operations](./advanced.md) diff --git a/kotlin/configuration/README.md b/kotlin/configuration/README.md new file mode 100644 index 0000000..d2bd78f --- /dev/null +++ b/kotlin/configuration/README.md @@ -0,0 +1,73 @@ +# Configuration + +This section covers configuration options for the Kotlin SDK. + +## Documents + +| Document | Description | +|----------|-------------| +| [KOptions](./koptions.md) | Kotlin-native option classes (Activity, Workflow, Retry, etc.) | +| [Data Conversion](./data-conversion.md) | kotlinx.serialization, Jackson configuration | +| [Interceptors](./interceptors.md) | Worker, Workflow, Activity interceptors | + +## Overview + +The Kotlin SDK provides native Kotlin configuration classes that: +- Accept `kotlin.time.Duration` directly +- Use named parameters with default values +- Follow immutable data class patterns +- Support `copy()` for creating variants + +## Quick Reference + +### KOptions Classes + +```kotlin +// Activity options +KActivityOptions( + startToCloseTimeout = 30.seconds, + retryOptions = KRetryOptions(maximumAttempts = 3) +) + +// Local activity options +KLocalActivityOptions( + startToCloseTimeout = 5.seconds, + localRetryThreshold = 10.seconds +) + +// Child workflow options +KChildWorkflowOptions( + workflowId = "child-123", + workflowExecutionTimeout = 1.hours +) + +// Workflow options (for client) +KWorkflowOptions( + workflowId = "order-123", + taskQueue = "orders", + workflowExecutionTimeout = 24.hours +) +``` + +### Data Conversion + +```kotlin +// kotlinx.serialization (default) +@Serializable +data class Order(val id: String, val items: List) + +// Jackson (for Java interop) +val converter = DefaultDataConverter.newDefaultInstance().withPayloadConverterOverrides( + JacksonJsonPayloadConverter(KotlinObjectMapperFactory.new()) +) +``` + +## Related + +- [KOptions](./koptions.md) - Full options reference +- [Data Conversion](./data-conversion.md) - Serialization configuration +- [Interceptors](./interceptors.md) - Cross-cutting concerns + +--- + +**Next:** [KOptions](./koptions.md) diff --git a/kotlin/configuration/data-conversion.md b/kotlin/configuration/data-conversion.md new file mode 100644 index 0000000..cf4d874 --- /dev/null +++ b/kotlin/configuration/data-conversion.md @@ -0,0 +1,128 @@ +# Data Conversion + +The Kotlin SDK uses `kotlinx.serialization` by default for JSON serialization. It provides compile-time safety, no reflection overhead, and native Kotlin support. + +## kotlinx.serialization (Default) + +Annotate data classes with `@Serializable`: + +```kotlin +@Serializable +data class Order( + val id: String, + val items: List, + val status: OrderStatus +) + +@Serializable +data class OrderItem( + val productId: String, + val quantity: Int +) + +@Serializable +enum class OrderStatus { PENDING, PROCESSING, COMPLETED } +``` + +No additional configuration needed—the SDK automatically uses `kotlinx.serialization` for classes annotated with `@Serializable`. + +### Custom JSON Configuration + +```kotlin +val client = WorkflowClient(service) { + dataConverter = KotlinxSerializationDataConverter { + ignoreUnknownKeys = true + prettyPrint = false // default + encodeDefaults = true + } +} +``` + +### Polymorphic Types + +For sealed classes and interfaces: + +```kotlin +@Serializable +sealed class PaymentMethod { + @Serializable + @SerialName("credit_card") + data class CreditCard(val number: String, val expiry: String) : PaymentMethod() + + @Serializable + @SerialName("bank_transfer") + data class BankTransfer(val accountNumber: String) : PaymentMethod() +} +``` + +### Custom Serializers + +For types that need custom serialization: + +```kotlin +@Serializable(with = BigDecimalSerializer::class) +data class Money(val amount: BigDecimal, val currency: String) + +object BigDecimalSerializer : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("BigDecimal", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: BigDecimal) = encoder.encodeString(value.toPlainString()) + override fun deserialize(decoder: Decoder) = BigDecimal(decoder.decodeString()) +} +``` + +## Jackson (Optional, for Java Interop) + +For mixed Java/Kotlin codebases or when integrating with existing Jackson-based infrastructure: + +```kotlin +val converter = DefaultDataConverter.newDefaultInstance().withPayloadConverterOverrides( + JacksonJsonPayloadConverter( + KotlinObjectMapperFactory.new() + ) +) + +val client = WorkflowClient(service) { + dataConverter = converter +} +``` + +> **Note:** Jackson requires the `jackson-module-kotlin` dependency and uses runtime reflection. Prefer `kotlinx.serialization` for pure Kotlin projects. + +### Custom Jackson Configuration + +```kotlin +val objectMapper = KotlinObjectMapperFactory.new().apply { + configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + registerModule(JavaTimeModule()) +} + +val converter = DefaultDataConverter.newDefaultInstance().withPayloadConverterOverrides( + JacksonJsonPayloadConverter(objectMapper) +) +``` + +## Comparison + +| Feature | kotlinx.serialization | Jackson | +|---------|----------------------|---------| +| Reflection | None (compile-time) | Required | +| Performance | Faster | Slower | +| Kotlin support | Native | Via module | +| Java interop | Limited | Excellent | +| Setup | `@Serializable` annotation | Automatic | +| Bundle size | Smaller | Larger | + +## Recommendations + +- **Pure Kotlin projects**: Use `kotlinx.serialization` +- **Mixed Java/Kotlin**: Consider Jackson for shared data types +- **Existing Jackson infrastructure**: Use Jackson for consistency + +## Related + +- [KOptions](./koptions.md) - Configuration options +- [Interceptors](./interceptors.md) - Cross-cutting concerns + +--- + +**Next:** [Interceptors](./interceptors.md) diff --git a/kotlin/configuration/interceptors.md b/kotlin/configuration/interceptors.md new file mode 100644 index 0000000..9dfaed5 --- /dev/null +++ b/kotlin/configuration/interceptors.md @@ -0,0 +1,521 @@ +# Interceptors + +Interceptors allow you to intercept workflow, activity, and client operations to add cross-cutting concerns like logging, metrics, tracing, and custom error handling. The Kotlin SDK provides: + +- **KWorkerInterceptor** - Intercepts workflow and activity executions on the worker side +- **KWorkflowClientInterceptor** - Intercepts client-side operations (start, signal, query, update) + +All interceptors are suspend-function-aware and integrate naturally with coroutines. + +## KWorkerInterceptor + +The main entry point for interceptors. Registered with `KWorkerFactory` and called when workflows or activities are instantiated. + +```kotlin +/** + * Intercepts workflow and activity executions. + * + * Prefer extending [KWorkerInterceptorBase] and overriding only the methods you need. + */ +interface KWorkerInterceptor { + /** + * Called when a workflow is instantiated. May create a [KWorkflowInboundCallsInterceptor]. + * The returned interceptor must forward all calls to [next]. + */ + fun interceptWorkflow(next: KWorkflowInboundCallsInterceptor): KWorkflowInboundCallsInterceptor + + /** + * Called when an activity task is received. May create a [KActivityInboundCallsInterceptor]. + * The returned interceptor must forward all calls to [next]. + */ + fun interceptActivity(next: KActivityInboundCallsInterceptor): KActivityInboundCallsInterceptor +} + +/** + * Base implementation that passes through all calls. Extend this class and override only needed methods. + */ +open class KWorkerInterceptorBase : KWorkerInterceptor { + override fun interceptWorkflow(next: KWorkflowInboundCallsInterceptor) = next + override fun interceptActivity(next: KActivityInboundCallsInterceptor) = next +} +``` + +## Registering Interceptors + +Interceptors are registered via `KWorkerFactory`: + +```kotlin +val factory = KWorkerFactory(client) { + workerInterceptors = listOf( + LoggingInterceptor() + ) +} + +val worker = factory.newWorker("task-queue") +worker.registerWorkflowImplementationTypes() +worker.registerActivitiesImplementations(MyActivitiesImpl()) + +factory.start() +``` + +## KWorkflowInboundCallsInterceptor + +Intercepts inbound calls to workflow execution (workflow method, signals, queries, updates). + +```kotlin +interface KWorkflowInboundCallsInterceptor { + /** Called when the workflow is instantiated. Use this to wrap the outbound interceptor. */ + suspend fun init(outboundCalls: KWorkflowOutboundCallsInterceptor) + + /** Called when the workflow main method is invoked. */ + suspend fun execute(input: KWorkflowInput): KWorkflowOutput + + /** Called when a signal is delivered to the workflow. */ + suspend fun handleSignal(input: KSignalInput) + + /** Called when a query is made to the workflow. Note: Queries must be synchronous. */ + fun handleQuery(input: KQueryInput): KQueryOutput + + /** Called to validate an update before execution. Throw an exception to reject. */ + fun validateUpdate(input: KUpdateInput) + + /** Called to execute an update after validation passes. */ + suspend fun executeUpdate(input: KUpdateInput): KUpdateOutput +} + +/** Base implementation that forwards all calls to the next interceptor. */ +open class KWorkflowInboundCallsInterceptorBase( + protected val next: KWorkflowInboundCallsInterceptor +) : KWorkflowInboundCallsInterceptor { + override suspend fun init(outboundCalls: KWorkflowOutboundCallsInterceptor) = next.init(outboundCalls) + override suspend fun execute(input: KWorkflowInput) = next.execute(input) + override suspend fun handleSignal(input: KSignalInput) = next.handleSignal(input) + override fun handleQuery(input: KQueryInput) = next.handleQuery(input) + override fun validateUpdate(input: KUpdateInput) = next.validateUpdate(input) + override suspend fun executeUpdate(input: KUpdateInput) = next.executeUpdate(input) +} +``` + +### Input/Output Classes + +```kotlin +data class KWorkflowInput(val header: Header, val arguments: Array) +data class KWorkflowOutput(val result: Any?) + +// Dynamic handlers can use encodedValues to decode raw payloads +data class KSignalInput( + val signalName: String, + val arguments: Array, + val encodedValues: KEncodedValues, + val eventId: Long, + val header: Header +) +data class KQueryInput( + val queryName: String, + val arguments: Array, + val encodedValues: KEncodedValues, + val header: Header +) +data class KQueryOutput(val result: Any?) +data class KUpdateInput( + val updateName: String, + val arguments: Array, + val encodedValues: KEncodedValues, + val header: Header +) +data class KUpdateOutput(val result: Any?) +``` + +## KWorkflowOutboundCallsInterceptor + +Intercepts outbound calls from workflow code to Temporal APIs (activities, child workflows, timers, etc.). + +All async operations are `suspend` functions. For parallel execution, use standard `async { }` pattern. + +```kotlin +interface KWorkflowOutboundCallsInterceptor { + // Activities - suspend, use async {} for parallel execution + suspend fun executeActivity(input: KActivityInvocationInput): R + suspend fun executeLocalActivity(input: KLocalActivityInvocationInput): R + + // Child Workflows - returns handle with Deferred result + suspend fun startChildWorkflow(input: KChildWorkflowInvocationInput): KChildWorkflowHandle + + // Timers + suspend fun delay(duration: Duration) + + // Await Conditions + suspend fun awaitCondition(timeout: Duration, reason: String, condition: () -> Boolean): Boolean + suspend fun awaitCondition(reason: String, condition: () -> Boolean) + + // Side Effects + fun sideEffect(resultClass: Class, func: () -> R): R + fun mutableSideEffect(id: String, resultClass: Class, updated: (R?, R?) -> Boolean, func: () -> R): R + + // Versioning + fun getVersion(changeId: String, minSupported: Int, maxSupported: Int): Int + + // Continue As New + fun continueAsNew(input: KContinueAsNewInput): Nothing + + // External Workflow Communication + suspend fun signalExternalWorkflow(input: KSignalExternalInput) + suspend fun cancelWorkflow(input: KCancelWorkflowInput) + + // Search Attributes and Memo + fun upsertTypedSearchAttributes(vararg updates: SearchAttributeUpdate<*>) + fun upsertMemo(memo: Map) + + // Utilities + fun newRandom(): Random + fun randomUUID(): UUID + fun currentTimeMillis(): Long +} + +/** + * Base implementation that forwards all calls to the next interceptor. + */ +open class KWorkflowOutboundCallsInterceptorBase( + protected val next: KWorkflowOutboundCallsInterceptor +) : KWorkflowOutboundCallsInterceptor { + override suspend fun executeActivity(input: KActivityInvocationInput) = next.executeActivity(input) + override suspend fun executeLocalActivity(input: KLocalActivityInvocationInput) = next.executeLocalActivity(input) + override suspend fun startChildWorkflow(input: KChildWorkflowInvocationInput) = next.startChildWorkflow(input) + override suspend fun delay(duration: Duration) = next.delay(duration) + override suspend fun awaitCondition(timeout: Duration, reason: String, condition: () -> Boolean) = + next.awaitCondition(timeout, reason, condition) + override suspend fun awaitCondition(reason: String, condition: () -> Boolean) = + next.awaitCondition(reason, condition) + override fun sideEffect(resultClass: Class, func: () -> R) = next.sideEffect(resultClass, func) + override fun mutableSideEffect(id: String, resultClass: Class, updated: (R?, R?) -> Boolean, func: () -> R) = + next.mutableSideEffect(id, resultClass, updated, func) + override fun getVersion(changeId: String, minSupported: Int, maxSupported: Int) = + next.getVersion(changeId, minSupported, maxSupported) + override fun continueAsNew(input: KContinueAsNewInput) = next.continueAsNew(input) + override suspend fun signalExternalWorkflow(input: KSignalExternalInput) = next.signalExternalWorkflow(input) + override suspend fun cancelWorkflow(input: KCancelWorkflowInput) = next.cancelWorkflow(input) + override fun upsertTypedSearchAttributes(vararg updates: SearchAttributeUpdate<*>) = + next.upsertTypedSearchAttributes(*updates) + override fun upsertMemo(memo: Map) = next.upsertMemo(memo) + override fun newRandom() = next.newRandom() + override fun randomUUID() = next.randomUUID() + override fun currentTimeMillis() = next.currentTimeMillis() +} +``` + +### Outbound Input/Output Classes + +```kotlin +/** + * Input for activity invocation. + */ +data class KActivityInvocationInput( + val activityName: String, + val resultClass: Class, + val resultType: Type, + val arguments: Array, + val options: KActivityOptions, + val header: Header +) + +/** + * Input for local activity invocation. + */ +data class KLocalActivityInvocationInput( + val activityName: String, + val resultClass: Class, + val resultType: Type, + val arguments: Array, + val options: KLocalActivityOptions, + val header: Header +) + +/** + * Input for child workflow invocation. + */ +data class KChildWorkflowInvocationInput( + val workflowId: String, + val workflowType: String, + val resultClass: Class, + val resultType: Type, + val arguments: Array, + val options: KChildWorkflowOptions, + val header: Header +) + +/** + * Input for continue-as-new. + */ +data class KContinueAsNewInput( + val workflowType: String?, + val options: KContinueAsNewOptions?, + val arguments: Array, + val header: Header +) + +/** + * Input for signaling external workflow. + */ +data class KSignalExternalInput( + val execution: WorkflowExecution, + val signalName: String, + val arguments: Array, + val header: Header +) + +/** + * Input for canceling external workflow. + */ +data class KCancelWorkflowInput( + val execution: WorkflowExecution, + val reason: String? +) +``` + +## KActivityInboundCallsInterceptor + +Intercepts inbound calls to activity execution. + +```kotlin +interface KActivityInboundCallsInterceptor { + /** Called when activity is initialized. Provides access to the activity execution context. */ + fun init(context: ActivityExecutionContext) + + /** Called when activity method is invoked. This is a suspend function to support suspend activities. */ + suspend fun execute(input: KActivityExecutionInput): KActivityExecutionOutput +} + +open class KActivityInboundCallsInterceptorBase( + protected val next: KActivityInboundCallsInterceptor +) : KActivityInboundCallsInterceptor { + override fun init(context: ActivityExecutionContext) = next.init(context) + override suspend fun execute(input: KActivityExecutionInput) = next.execute(input) +} + +data class KActivityExecutionInput(val header: Header, val arguments: Array) +data class KActivityExecutionOutput(val result: Any?) +``` + +## Example: Logging Interceptor + +Uses standard logging with MDC. The SDK automatically populates MDC with context (workflowId, runId, activityType, etc.). + +```kotlin +class LoggingInterceptor : KWorkerInterceptorBase() { + + override fun interceptWorkflow( + next: KWorkflowInboundCallsInterceptor + ): KWorkflowInboundCallsInterceptor { + return LoggingWorkflowInterceptor(next) + } + + override fun interceptActivity( + next: KActivityInboundCallsInterceptor + ): KActivityInboundCallsInterceptor { + return LoggingActivityInterceptor(next) + } +} + +private class LoggingWorkflowInterceptor( + next: KWorkflowInboundCallsInterceptor +) : KWorkflowInboundCallsInterceptorBase(next) { + + // Standard SLF4J logger - MDC is populated automatically by the SDK + private val log = LoggerFactory.getLogger(LoggingWorkflowInterceptor::class.java) + + override suspend fun execute(input: KWorkflowInput): KWorkflowOutput { + log.info("Workflow started with ${input.arguments.size} arguments") + val startTime = KWorkflow.currentTimeMillis() + + return try { + val result = next.execute(input) + val duration = KWorkflow.currentTimeMillis() - startTime + log.info("Workflow completed in ${duration}ms") + result + } catch (e: Exception) { + val duration = KWorkflow.currentTimeMillis() - startTime + log.error("Workflow failed after ${duration}ms", e) + throw e + } + } + + override suspend fun handleSignal(input: KSignalInput) { + log.info("Signal received: ${input.signalName}") + next.handleSignal(input) + } + + override fun handleQuery(input: KQueryInput): KQueryOutput { + log.debug("Query received: ${input.queryName}") + return next.handleQuery(input) + } + + override suspend fun executeUpdate(input: KUpdateInput): KUpdateOutput { + log.info("Update received: ${input.updateName}") + return try { + val result = next.executeUpdate(input) + log.info("Update ${input.updateName} completed") + result + } catch (e: Exception) { + log.error("Update ${input.updateName} failed", e) + throw e + } + } +} + +private class LoggingActivityInterceptor( + next: KActivityInboundCallsInterceptor +) : KActivityInboundCallsInterceptorBase(next) { + + // Standard SLF4J logger - MDC is populated automatically by the SDK + private val log = LoggerFactory.getLogger(LoggingActivityInterceptor::class.java) + + override suspend fun execute(input: KActivityExecutionInput): KActivityExecutionOutput { + val ctx = KActivityContext.current() + + log.info("Activity ${ctx.info.activityType} started") + val startTime = System.currentTimeMillis() + + return try { + val result = next.execute(input) + val duration = System.currentTimeMillis() - startTime + log.info("Activity ${ctx.info.activityType} completed in ${duration}ms") + result + } catch (e: Exception) { + val duration = System.currentTimeMillis() - startTime + log.error("Activity ${ctx.info.activityType} failed after ${duration}ms", e) + throw e + } + } +} +``` + +## KWorkflowClientInterceptor + +Client interceptors intercept client-side operations such as starting workflows, sending signals, executing queries, and updates. + +```kotlin +/** + * Intercepts client-side workflow operations. + * + * Prefer extending [KWorkflowClientInterceptorBase] and overriding only the methods you need. + */ +interface KWorkflowClientInterceptor { + /** Called when starting a workflow */ + suspend fun startWorkflow(input: KStartWorkflowInput, next: suspend (KStartWorkflowInput) -> KWorkflowHandleWithResult<*, R>): KWorkflowHandleWithResult<*, R> + + /** Called when signaling a workflow */ + suspend fun signalWorkflow(input: KSignalWorkflowInput, next: suspend (KSignalWorkflowInput) -> Unit) + + /** Called when querying a workflow */ + suspend fun queryWorkflow(input: KQueryWorkflowInput, next: suspend (KQueryWorkflowInput) -> R): R + + /** Called when executing an update on a workflow */ + suspend fun executeUpdate(input: KExecuteUpdateInput, next: suspend (KExecuteUpdateInput) -> R): R + + /** Called when starting an update on a workflow */ + suspend fun startUpdate(input: KStartUpdateInput, next: suspend (KStartUpdateInput) -> KUpdateHandle): KUpdateHandle + + /** Called when canceling a workflow */ + suspend fun cancelWorkflow(input: KCancelWorkflowInput, next: suspend (KCancelWorkflowInput) -> Unit) + + /** Called when terminating a workflow */ + suspend fun terminateWorkflow(input: KTerminateWorkflowInput, next: suspend (KTerminateWorkflowInput) -> Unit) +} + +/** + * Base implementation that passes through all calls. + */ +open class KWorkflowClientInterceptorBase : KWorkflowClientInterceptor { + override suspend fun startWorkflow(input: KStartWorkflowInput, next: suspend (KStartWorkflowInput) -> KWorkflowHandleWithResult<*, R>) = next(input) + override suspend fun signalWorkflow(input: KSignalWorkflowInput, next: suspend (KSignalWorkflowInput) -> Unit) = next(input) + override suspend fun queryWorkflow(input: KQueryWorkflowInput, next: suspend (KQueryWorkflowInput) -> R) = next(input) + override suspend fun executeUpdate(input: KExecuteUpdateInput, next: suspend (KExecuteUpdateInput) -> R) = next(input) + override suspend fun startUpdate(input: KStartUpdateInput, next: suspend (KStartUpdateInput) -> KUpdateHandle) = next(input) + override suspend fun cancelWorkflow(input: KCancelWorkflowInput, next: suspend (KCancelWorkflowInput) -> Unit) = next(input) + override suspend fun terminateWorkflow(input: KTerminateWorkflowInput, next: suspend (KTerminateWorkflowInput) -> Unit) = next(input) +} +``` + +### Client Interceptor Input Classes + +```kotlin +data class KStartWorkflowInput( + val workflowId: String, + val workflowType: String, + val taskQueue: String, + val arguments: Array, + val options: KWorkflowOptions, + val header: Header +) + +data class KSignalWorkflowInput( + val workflowId: String, + val runId: String?, + val signalName: String, + val arguments: Array, + val header: Header +) + +data class KQueryWorkflowInput( + val workflowId: String, + val runId: String?, + val queryName: String, + val arguments: Array, + val resultClass: Class, + val header: Header +) + +data class KExecuteUpdateInput( + val workflowId: String, + val runId: String?, + val updateName: String, + val arguments: Array, + val options: KUpdateOptions, + val resultClass: Class, + val header: Header +) + +data class KStartUpdateInput( + val workflowId: String, + val runId: String?, + val updateName: String, + val arguments: Array, + val options: KStartUpdateOptions, + val resultClass: Class, + val header: Header +) + +data class KCancelWorkflowInput( + val workflowId: String, + val runId: String? +) + +data class KTerminateWorkflowInput( + val workflowId: String, + val runId: String?, + val reason: String? +) +``` + +### Registering Client Interceptors + +```kotlin +val client = KClient.connect( + KClientOptions( + target = "localhost:7233", + namespace = "default", + interceptors = listOf( + TracingClientInterceptor() + ) + ) +) +``` + +## Related + +- [KOptions](./koptions.md) - Configuration options +- [Worker Setup](../worker/setup.md) - Registering interceptors + +--- + +**Next:** [Workflows](../workflows/README.md) diff --git a/kotlin/configuration/koptions.md b/kotlin/configuration/koptions.md new file mode 100644 index 0000000..92a8120 --- /dev/null +++ b/kotlin/configuration/koptions.md @@ -0,0 +1,297 @@ +# KOptions Classes + +For a fully idiomatic Kotlin experience, the SDK provides dedicated `KOptions` data classes that accept `kotlin.time.Duration` directly, use named parameters with default values, and follow immutable data class patterns. + +> **Note on Experimental Features:** Properties marked with `@Experimental` mirror experimental features in the Java SDK. These are subject to change or removal without notice. Use with caution in production code. + +## KActivityOptions + +```kotlin +/** + * Kotlin-native activity options with Duration support. + * All timeout properties accept kotlin.time.Duration directly. + * + * IMPORTANT: At least one of startToCloseTimeout or scheduleToCloseTimeout must be specified. + */ +data class KActivityOptions( + val startToCloseTimeout: Duration? = null, + val scheduleToCloseTimeout: Duration? = null, + val scheduleToStartTimeout: Duration? = null, + val heartbeatTimeout: Duration? = null, + val taskQueue: String? = null, + val retryOptions: KRetryOptions? = null, + val cancellationType: ActivityCancellationType? = null, // Java default: TRY_CANCEL + val disableEagerExecution: Boolean = false, + // Experimental + @Experimental val summary: String? = null, + @Experimental val priority: Priority? = null +) { + init { + require(startToCloseTimeout != null || scheduleToCloseTimeout != null) { + "At least one of startToCloseTimeout or scheduleToCloseTimeout must be specified" + } + } +} +``` + +**Usage:** + +```kotlin +val result = KWorkflow.executeActivity( + GreetingActivities::composeGreeting, + KActivityOptions( + startToCloseTimeout = 30.seconds, + scheduleToCloseTimeout = 5.minutes, + heartbeatTimeout = 10.seconds, + retryOptions = KRetryOptions( + initialInterval = 1.seconds, + maximumAttempts = 3 + ) + ), + "Hello", "World" +) +``` + +## KLocalActivityOptions + +```kotlin +/** + * Kotlin-native local activity options. + */ +data class KLocalActivityOptions( + val startToCloseTimeout: Duration? = null, + val scheduleToCloseTimeout: Duration? = null, + val scheduleToStartTimeout: Duration? = null, + val localRetryThreshold: Duration? = null, + val retryOptions: KRetryOptions? = null, + val doNotIncludeArgumentsIntoMarker: Boolean = false, + // Experimental + @Experimental val summary: String? = null +) +``` + +**Usage:** + +```kotlin +val validated = KWorkflow.executeLocalActivity( + ValidationActivities::validate, + KLocalActivityOptions( + startToCloseTimeout = 5.seconds, + localRetryThreshold = 10.seconds + ), + input +) +``` + +## KRetryOptions + +```kotlin +/** + * Kotlin-native retry options. + */ +data class KRetryOptions( + val initialInterval: Duration = 1.seconds, + val backoffCoefficient: Double = 2.0, + val maximumInterval: Duration? = null, + val maximumAttempts: Int = 0, // 0 = unlimited + val doNotRetry: List = emptyList() // Exception type names +) +``` + +> **Note:** `doNotRetry` contains fully-qualified exception class names (e.g., `"java.lang.IllegalArgumentException"`, `"com.example.BusinessException"`). Activities throwing these exceptions will not be retried. + +## KChildWorkflowOptions + +```kotlin +/** + * Kotlin-native child workflow options. + * All fields are optional - null values inherit from parent workflow or use Java SDK defaults. + */ +data class KChildWorkflowOptions( + val namespace: String? = null, + val workflowId: String? = null, + val workflowIdReusePolicy: WorkflowIdReusePolicy? = null, + val workflowRunTimeout: Duration? = null, + val workflowExecutionTimeout: Duration? = null, + val workflowTaskTimeout: Duration? = null, + val taskQueue: String? = null, + val retryOptions: KRetryOptions? = null, + val cronSchedule: String? = null, + val parentClosePolicy: ParentClosePolicy? = null, + val memo: Map? = null, + val searchAttributes: SearchAttributes? = null, + val cancellationType: ChildWorkflowCancellationType? = null, + // Experimental + @Experimental val staticSummary: String? = null, + @Experimental val staticDetails: String? = null, + @Experimental val priority: Priority? = null +) +``` + +**Usage:** + +```kotlin +val childResult = KWorkflow.executeChildWorkflow( + ChildWorkflow::process, + KChildWorkflowOptions( + workflowId = "child-123", + workflowExecutionTimeout = 1.hours, + retryOptions = KRetryOptions(maximumAttempts = 3) + ), + data +) +``` + +## KWorkflowOptions + +```kotlin +/** + * Kotlin-native workflow options for client execution. + * workflowId and taskQueue are optional - if not provided, will use defaults or generate UUID. + */ +data class KWorkflowOptions( + val workflowId: String? = null, + val workflowIdReusePolicy: WorkflowIdReusePolicy? = null, + val workflowIdConflictPolicy: WorkflowIdConflictPolicy? = null, + val workflowRunTimeout: Duration? = null, + val workflowExecutionTimeout: Duration? = null, + val workflowTaskTimeout: Duration? = null, + val taskQueue: String? = null, + val retryOptions: KRetryOptions? = null, + val cronSchedule: String? = null, + val memo: Map? = null, + val searchAttributes: SearchAttributes? = null, + val disableEagerExecution: Boolean = true, + val startDelay: Duration? = null, + val contextPropagators: List? = null, + // Experimental + @Experimental val staticSummary: String? = null, + @Experimental val staticDetails: String? = null, + @Experimental val priority: Priority? = null, + @Experimental val requestId: String? = null, + @Experimental val completionCallbacks: List? = null, + @Experimental val links: List? = null, + @Experimental val onConflictOptions: KOnConflictOptions? = null, + @Experimental val versioningOverride: VersioningOverride? = null +) +``` + +**Usage:** + +```kotlin +val result = client.executeWorkflow( + OrderWorkflow::processOrder, + KWorkflowOptions( + workflowId = "order-123", + taskQueue = "orders", + workflowExecutionTimeout = 24.hours, + workflowRunTimeout = 1.hours + ), + order +) +``` + +## KOnConflictOptions + +```kotlin +/** + * Options for handling workflow ID conflicts when using USE_EXISTING conflict policy. + * These options control what gets attached to an existing workflow when a start request + * conflicts with it. + * + * @Experimental This API is experimental and may change. + */ +@Experimental +data class KOnConflictOptions( + val attachRequestId: Boolean = false, + val attachCompletionCallbacks: Boolean = false, + val attachLinks: Boolean = false +) { + init { + if (attachCompletionCallbacks) { + require(attachRequestId) { + "attachRequestId must be true if attachCompletionCallbacks is true" + } + } + } + + fun toJavaOptions(): OnConflictOptions +} +``` + +**Usage:** + +```kotlin +val options = KWorkflowOptions( + workflowId = "my-workflow", + taskQueue = "my-queue", + workflowIdConflictPolicy = WorkflowIdConflictPolicy.WORKFLOW_ID_CONFLICT_POLICY_USE_EXISTING, + onConflictOptions = KOnConflictOptions( + attachRequestId = true, + attachCompletionCallbacks = true + ) +) +``` + +## KContinueAsNewOptions + +```kotlin +/** + * Options for continuing a workflow as a new execution. + * All fields are optional - null values inherit from the current workflow. + */ +data class KContinueAsNewOptions( + val workflowRunTimeout: Duration? = null, + val taskQueue: String? = null, + val retryOptions: KRetryOptions? = null, + val workflowTaskTimeout: Duration? = null, + val memo: Map? = null, + val searchAttributes: SearchAttributes? = null, + val contextPropagators: List? = null +) +``` + +## Copying with Modifications + +Data classes support the `copy()` function for creating variants: + +```kotlin +val baseOptions = KActivityOptions( + startToCloseTimeout = 30.seconds, + retryOptions = KRetryOptions(maximumAttempts = 3) +) + +// Create variant with different timeout +val longRunningOptions = baseOptions.copy( + startToCloseTimeout = 5.minutes, + heartbeatTimeout = 30.seconds +) +``` + +## KOptions vs DSL Builders + +The Kotlin SDK provides two approaches for configuring options: + +1. **KOptions (Recommended)** - Native Kotlin data classes designed for the Kotlin SDK +2. **DSL Builders** - Extension functions on Java SDK builders, provided as a stopgap for using Kotlin with the Java SDK + +> **Important:** When using the Kotlin SDK (`KWorkflow`, `KClient`, etc.), always use KOptions classes. The DSL builders (`ActivityOptions { }`, `WorkflowOptions { }`, etc.) exist only for compatibility when using Kotlin with the Java SDK directly and should not be used with the Kotlin SDK APIs. + +| Aspect | DSL Builder (Java SDK interop) | KOptions (Kotlin SDK) | +|--------|--------------------------------|------------------------| +| Duration | Requires `.toJava()` conversion | Native `kotlin.time.Duration` | +| Syntax | `setStartToCloseTimeout(...)` | `startToCloseTimeout = ...` | +| Defaults | Must check Java defaults | Visible in constructor | +| Immutability | Mutable builder | Immutable data class | +| Copy | Manual rebuild | `copy()` function | +| IDE | Limited autocomplete | Full parameter hints | +| Usage | Java SDK only | Kotlin SDK | + +## Related + +- [Data Conversion](./data-conversion.md) - Serialization configuration +- [Interceptors](./interceptors.md) - Cross-cutting concerns + +--- + +**Next:** [Data Conversion](./data-conversion.md) diff --git a/kotlin/implementation/README.md b/kotlin/implementation/README.md new file mode 100644 index 0000000..cac02c5 --- /dev/null +++ b/kotlin/implementation/README.md @@ -0,0 +1,19 @@ +# Implementation Details + +This folder contains internal design documents and implementation plans for the Kotlin SDK. + +## Documents + +| Document | Description | +|----------|-------------| +| [sdk-proposal.md](./sdk-proposal.md) | Original SDK proposal | +| [sdk-implementation.md](./sdk-implementation.md) | Implementation details and architecture | +| [implementation-plan.md](./implementation-plan.md) | Implementation phases and milestones | +| [suspend-activities-design.md](./suspend-activities-design.md) | Design for suspend activity support | +| [test-framework-design.md](./test-framework-design.md) | Testing framework design | +| [test-framework-implementation-design.md](./test-framework-implementation-design.md) | Testing framework implementation details | +| [test-framework-implementation-plan.md](./test-framework-implementation-plan.md) | Testing framework implementation plan | + +## Related + +For public API documentation, see the [parent folder](../). diff --git a/kotlin/implementation/implementation-plan.md b/kotlin/implementation/implementation-plan.md new file mode 100644 index 0000000..28db030 --- /dev/null +++ b/kotlin/implementation/implementation-plan.md @@ -0,0 +1,131 @@ +# Kotlin SDK Implementation Plan + +## Phase 1: Core Coroutine Infrastructure ✅ COMPLETE + +### 1.1 Java SDK Refactoring +- ✅ Add `WorkflowImplementationFactory` interface +- ✅ Update `Worker` to support multiple factories +- ✅ Update `SyncWorkflowWorker` with composite factory +- ✅ Add `Async.await()` methods returning `Promise` +- ✅ Expose `ReplayWorkflow` and `ReplayWorkflowContext` for SPI + +### 1.2 Kotlin Coroutine Runtime +- ✅ Implement `KotlinCoroutineDispatcher` (deterministic execution with `Delay`) +- ✅ Implement `KotlinReplayWorkflow` +- ✅ Implement `KotlinWorkflowContext` +- ✅ Implement `KotlinWorkflowImplementationFactory` + +### 1.3 Core Kotlin APIs +- ✅ `KWorkflow` object with string-based activity/child workflow execution +- ✅ `KWorkflowInfo` wrapper with null safety +- ✅ Duration extensions (`kotlin.time.Duration` ↔ `java.time.Duration`) +- ✅ `KWorkflow.awaitCondition()` suspend function +- ✅ Standard `delay()` support via `Delay` interface +- ✅ Standard `coroutineScope { async { } }` for parallel execution + +### 1.4 Worker Integration +- ✅ `KotlinPlugin` for enabling coroutine support +- ✅ Worker extension for registering Kotlin workflows +- ✅ Suspend function detection in registration + +### 1.5 Signals, Queries, Updates +- ✅ Signal handler registration (annotation-based and dynamic) with suspend support +- ✅ Query methods (annotation-based and dynamic) +- ✅ Update methods (annotation-based only) + +--- + +## Phase 2: Typed APIs & Full Feature Set ✅ COMPLETE + +### 2.1 Typed Activity Execution ✅ +- ✅ `KFunction`-based `executeActivity()` overloads (0-6 args) +- ✅ Type extraction from method references +- ✅ Local activity support with `executeLocalActivity()` +- ✅ Suspend activity support with `registerSuspendActivities()` + +### 2.2 Typed Child Workflow Execution ✅ +- ✅ Typed `executeChildWorkflow()` with method references +- ✅ `startChildWorkflow()` returning `KChildWorkflowHandle` +- ✅ `KChildWorkflowHandle` interface (signal, cancel, result) +- ✅ Optional `KChildWorkflowOptions` (uses defaults when omitted) + +### 2.3 Update Enhancements ✅ +- ✅ Dynamic update handler registration (`registerUpdateHandler`, `registerDynamicUpdateHandler`) +- ✅ Update validator support (`@UpdateValidatorMethod`) - Uses Java SDK annotations +- ✅ `KEncodedValues` for dynamic handlers to access raw payloads + +### 2.4 Client API ✅ +- ✅ `KWorkflowClient` - Kotlin client with suspend functions and DSL constructor +- ✅ `KWorkflowHandle` - typed handle for signals/queries/updates +- ✅ `KTypedWorkflowHandle` - extends KWorkflowHandle with typed result +- ✅ `WorkflowHandle` - untyped handle (string-based operations) +- ✅ `KUpdateHandle` - handle for async update execution +- ✅ `startWorkflow()`, `executeWorkflow()` suspend functions (0-6 args) +- ✅ `signalWithStart()` +- ✅ `updateWithStart()` - Atomically start workflow + send update + - ✅ `withStartWorkflowOperation()` factory methods (0-6 workflow args, regular and suspend) + - ✅ `startUpdateWithStart()` / `executeUpdateWithStart()` with `KUpdateWithStartOptions` (0-6 update args) + - ✅ `KWithStartWorkflowOperation` to capture workflow start metadata + - ✅ `KUpdateWithStartOptions` with waitForStage and updateId options +- ✅ `getWorkflowHandle()` and `getUntypedWorkflowHandle()` + +### 2.5 Worker API ✅ +- ✅ `KWorkerFactory` - Kotlin worker factory with KotlinPlugin pre-configured +- ✅ `KWorker` - Kotlin worker wrapper with reified generics, KClass support, DSL options for workflow/activity/Nexus registration +- ✅ `KWorkerFactory.newWorker()` returns `KWorker` for idiomatic Kotlin usage +- ✅ DSL builders for worker options + +### 2.6 Kotlin Activity API ✅ +- ✅ `KActivityContext.current()` (entry point for activity APIs) +- ✅ `ctx.info`, `ctx.heartbeat()`, `ctx.lastHeartbeatDetails()` methods +- ✅ MDC populated automatically with activity context for standard logging +- ✅ `KActivityInfo` with null safety +- ✅ Suspend activity support via `SuspendActivityWrapper` + +### 2.7 Kotlin Workflow API ✅ +- ✅ MDC populated automatically with workflow context for standard logging +- ✅ `KWorkflow.async {}` for eager parallel execution +- ✅ `KWorkflow.continueAsNew()` with `KContinueAsNewOptions` +- ✅ `KWorkflow.retry()` for workflow-level retry with exponential backoff + +--- + +## Phase 3: Testing Framework & Interceptors ✅ COMPLETE + +### 3.1 Test Environment ✅ COMPLETE +- ✅ `KTestActivityEnvironment` - typed executeActivity/executeLocalActivity, suspend activity support, heartbeat/cancellation testing +- ✅ `KTestWorkflowEnvironment` - worker creation (returns `KWorker`), client access, time manipulation, delayed callbacks, lifecycle management +- ✅ `KTestEnvironmentOptions` and `KTestEnvironmentOptionsBuilder` - DSL configuration +- ✅ `KTestActivityExtension` - JUnit 5 extension with parameter resolution and lifecycle management +- ✅ `KTestWorkflowExtension` - JUnit 5 extension with workflow stub injection, diagnostics on failure +- ✅ `@WorkflowInitialTime` annotation for test-specific initial time +- ✅ Time skipping utilities (`sleep()`, `registerDelayedCallback()`) +- ✅ Search attribute registration in test environment + +### 3.2 Mocking Support ✅ COMPLETE +- ✅ `KActivityMockRegistry` - thread-safe registry for activity mocks +- ✅ `KMockDynamicActivityHandler` - dynamic activity handler routing calls to mocks +- ✅ Support for both regular and suspend activity mocks +- ✅ Integration with `KTestWorkflowExtension` for automatic mock registration + +### 3.3 Interceptors ✅ COMPLETE (see [interceptors.md](../configuration/interceptors.md)) +- ✅ `KWorkerInterceptor` interface with `KWorkerInterceptorBase` +- ✅ `KWorkflowInboundCallsInterceptor` with suspend functions and input/output data classes +- ✅ `KWorkflowOutboundCallsInterceptor` with full API (activities, child workflows, timers, side effects, etc.) +- ✅ `KActivityInboundCallsInterceptor` with suspend support +- ✅ Base classes for convenience (`*Base` classes) +- ✅ `RootWorkflowOutboundCallsInterceptor` - terminal outbound interceptor implementation +- ✅ `WorkflowContextElement` - ThreadContextElement for workflow context propagation to interceptors +- ✅ `KEncodedValues` for dynamic handlers to access raw payloads +- ✅ Interceptor chain integration in `KotlinReplayWorkflow` +- ✅ KWorkflow static methods routed through outbound interceptor (newRandom, randomUUID, currentTimeMillis) +- ✅ Integration test: `TracingInterceptorIntegrationTest` + +--- + +## Cross-Cutting Concerns (All Phases) + +- Documentation and examples +- Migration guide from Java SDK +- Integration tests +- Compatibility testing (Java ↔ Kotlin interop) diff --git a/kotlin/implementation/sdk-implementation.md b/kotlin/implementation/sdk-implementation.md new file mode 100644 index 0000000..7ee8672 --- /dev/null +++ b/kotlin/implementation/sdk-implementation.md @@ -0,0 +1,1016 @@ +# Kotlin SDK Implementation Details + +This document describes the internal architecture and implementation details for the Temporal Kotlin SDK. + +For public API and developer experience, see the [SDK API documentation](../README.md). + +## Phases + +* **Phase 1 (COMPLETE)** - Coroutine-based workflows, untyped activity/child workflow execution, pluggable WorkflowImplementationFactory, core Kotlin idioms (Duration, null safety, KWorkflowInfo), signals/queries (annotation + dynamic handlers), updates (annotation-based), standard `delay()` and `coroutineScope { async { } }` support +* **Phase 2** - Typed activity execution, typed child workflow execution, KChildWorkflowHandle, dynamic update handlers, update validators, KActivity/KActivityInfo/KActivityExecutionContext wrappers, client & worker API (KWorkflowClient, KWorkerFactory, KWorkflowHandle, startWorkflow, signalWithStart, etc.) +* **Phase 3** - Interceptor interfaces, testing framework + +> **Note:** Nexus support is a separate project and will be addressed independently. + +## Relationship to Java SDK + +The Kotlin SDK builds on top of the Java SDK rather than replacing it: + +* **Shared infrastructure**: Uses the same gRPC client, data conversion, and service client +* **Interoperability**: Kotlin workflows can call Java activities and vice versa +* **Gradual adoption**: Teams can mix Java and Kotlin workflows in the same worker +* **Existing module**: Extends the existing `temporal-kotlin` module which already provides DSL extensions + +## Repository/Package Strategy + +### Repository + +The Kotlin SDK will continue to live in the `temporal-kotlin` module within the `sdk-java` repository: + +* Pros: + * Shared build infrastructure + * Easier to maintain version compatibility + * Single release process +* Cons: + * Ties Kotlin SDK releases to Java SDK releases + +### Package Naming + +* Core workflow APIs: `io.temporal.kotlin.workflow` +* Worker APIs: `io.temporal.kotlin.worker` +* Interceptors: `io.temporal.kotlin.interceptors` +* Existing extensions remain in their current packages (e.g., `io.temporal.client`) + +### Maven Artifacts + +```xml + + io.temporal + temporal-kotlin + N.N.N + +``` + +Additional dependency on kotlinx-coroutines: +```xml + + org.jetbrains.kotlinx + kotlinx-coroutines-core + 1.7.x + +``` + +## Existing Kotlin Classes (Retained) + +The `temporal-kotlin` module already provides Kotlin extensions for the Java SDK: + +| File | Purpose | Status | +|------|---------|--------| +| `TemporalDsl.kt` | `@DslMarker` for type-safe builders | **Keep as-is** | +| `*OptionsExt.kt` (15 files) | DSL builders for all Options classes | **Keep as-is** | +| `WorkflowClientExt.kt` | Reified `newWorkflowStub()`, DSL extensions | **Keep as-is** | +| `WorkflowStubExt.kt` | Reified `getResult()` | **Keep as-is** | +| `WorkerFactoryExt.kt` | `WorkerFactory()` constructor-like DSL | **Keep as-is** | +| `WorkerExt.kt` | Reified `registerWorkflowImplementationType()` | **Keep as-is** | +| `WorkflowMetadata.kt` | `workflowName()`, `workflowSignalName()` | **Keep as-is** | +| `ActivityMetadata.kt` | `activityName(Interface::method)` | **Keep as-is** | +| `KotlinObjectMapperFactory.kt` | Jackson ObjectMapper for Kotlin | **Keep as-is** | +| `KotlinMethodReferenceDisassemblyService.kt` | Kotlin method references in `Async` | **Keep as-is** | + +## New Classes (Kotlin SDK) + +| Class | Purpose | Status | +|-------|---------|--------| +| **Core Workflow** | | | +| `KWorkflow` | Entry point for workflow APIs (like `Workflow` in Java) | ✅ Done | +| `KotlinWorkflowContext` | Internal workflow execution context | ✅ Done | +| `KotlinCoroutineDispatcher` | Deterministic coroutine dispatcher with `Delay` implementation | ✅ Done | +| `KWorkerInterceptor` | Interceptor interface with suspend functions | Phase 3 | +| **Factory/Registration** | | | +| `KotlinPlugin` | Plugin for enabling coroutine support and registering interceptors | ✅ Done | +| `KotlinWorkflowImplementationFactory` | Implements `WorkflowImplementationFactory` for coroutine workflows | ✅ Done | +| `KotlinWorkflowDefinition` | Metadata about a Kotlin workflow type | ✅ Done | +| `KotlinReplayWorkflow` | Implements `ReplayWorkflow` using coroutines | ✅ Done | +| `WorkerExt.kt` (additions) | Extension `registerKotlinWorkflowImplementationTypes()` | Phase 2 | +| **Kotlin Wrappers** | | | +| `KWorkflowInfo` | Kotlin wrapper for WorkflowInfo with nullable types | ✅ Done | +| `KActivity` | Entry point for activity APIs (like `Activity` in Java) | Phase 2 | +| `KActivityInfo` | Kotlin wrapper for ActivityInfo with nullable types | Phase 2 | +| `KActivityExecutionContext` | Kotlin wrapper for ActivityExecutionContext | Phase 2 | +| **Client & Worker API** | | | +| `KWorkflowClient` | Kotlin client with suspend functions for starting/executing workflows | Phase 2 | +| `KWorkerFactory` | Kotlin worker factory with KotlinPlugin pre-configured | Phase 2 | +| `WorkflowHandle` | Untyped workflow handle (string-based signals/queries) | Phase 2 | +| `KWorkflowHandle` | Typed workflow handle for signals/queries/updates | Phase 2 | +| `KTypedWorkflowHandle` | Extends KWorkflowHandle with typed result (returned by startWorkflow) | Phase 2 | +| `KUpdateHandle` | Handle for async update execution | Phase 2 | +| `KChildWorkflowHandle` | Handle for interacting with started child workflows (signal, cancel, result) | Phase 2 | +| **Extensions** | | | +| `DurationExt.kt` | Conversions between `kotlin.time.Duration` and `java.time.Duration` | ✅ Done | +| `PromiseExt.kt` | `Promise.toDeferred()` to bridge Java SDK to standard coroutines | ✅ Done | + +> **Note:** We deliberately **do not** have `KActivityHandle`. Instead, users use standard `coroutineScope { async { } }` with `Deferred` for parallel activity execution. This follows the design principle of using idiomatic Kotlin patterns instead of custom APIs. However, `KChildWorkflowHandle` is provided because child workflows support signaling, which requires a handle. + +## Unified Worker Architecture + +The SDK uses a **single unified `WorkerFactory`** that supports both Java and Kotlin workflows. The execution model (thread-based vs coroutine-based) is determined **per workflow instance**, not per worker. This is achieved through a pluggable `WorkflowImplementationFactory` interface. + +``` +┌─────────────────────────────────────────────────────────┐ +│ WorkerFactory │ +│ │ +│ registerWorkflowImplementationTypes(...) ─────────┐ │ +│ registerWorkflowImplementationFactory(...) │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ WorkflowImplementationFactory Registry │ │ +│ │ │ │ +│ │ ┌─────────────────┐ ┌──────────────────────┐ │ │ +│ │ │ POJOWorkflow │ │ KotlinWorkflow │ │ │ +│ │ │ Implementation │ │ Implementation │ │ │ +│ │ │ Factory │ │ Factory │ │ │ +│ │ │ (Java SDK) │ │ (temporal-kotlin) │ │ │ +│ │ └────────┬────────┘ └──────────┬───────────┘ │ │ +│ └───────────┼──────────────────────┼─────────────┘ │ +└──────────────┼──────────────────────┼─────────────────┘ + │ │ + ▼ ▼ +┌──────────────────────┐ ┌──────────────────────────────┐ +│ Java workflow task │ │ Kotlin workflow task │ +│ │ │ │ +│ → DeterministicRunner│ │ → KotlinCoroutineDispatcher│ +│ → Thread-based exec │ │ → Coroutine-based exec │ +└──────────────────────┘ └──────────────────────────────┘ +``` + +**Key benefits:** + +| Benefit | Description | +|---------|-------------| +| Single worker type | No need for separate worker infrastructure | +| Mixed workflows | Java and Kotlin workflows on the same task queue | +| Gradual migration | Convert workflows one at a time | +| No Kotlin in Java SDK | All coroutine code stays in `temporal-kotlin` | +| Per-instance execution | Dispatcher is per workflow instance, not per worker | + +### KWorkerFactory vs Unified Architecture + +The unified worker architecture describes the **internal implementation**—how Java and Kotlin workflows coexist in the same worker. The **API surface** provides two ways to configure this: + +| API | Use Case | What It Does | +|-----|----------|--------------| +| `KWorkerFactory` | Pure Kotlin applications | Convenience wrapper that creates `WorkerFactory` with `KotlinPlugin` pre-configured | +| `WorkerFactory` + `KotlinPlugin` | Mixed Java/Kotlin or Java-main apps | Explicit plugin registration for more control | + +Both approaches produce the same result—a worker that can handle both Java and Kotlin workflows on the same task queue: + +```kotlin +// Option 1: KWorkerFactory (recommended for Kotlin apps) +val factory = KWorkerFactory(client) { + maxWorkflowThreadCount = 800 +} + +// Option 2: Explicit plugin (for Java-main or mixed scenarios) +val factory = WorkerFactory.newInstance(client, WorkerFactoryOptions.newBuilder() + .addPlugin(KotlinPlugin()) + .build()) +``` + +`KWorkerFactory` is not a separate worker type—it's a Kotlin-idiomatic API that wraps the standard `WorkerFactory`. Under the hood, both options use the same unified architecture with pluggable `WorkflowImplementationFactory`. + +## Java SDK Refactoring for Pluggability + +To support the Kotlin coroutine-based execution model, the Java SDK requires refactoring to make workflow execution pluggable. The key principle is that the Java SDK remains **completely Kotlin-agnostic**—it only provides extension points that the `temporal-kotlin` module can use. + +### Required Changes to `temporal-sdk` + +#### 1. New `WorkflowImplementationFactory` Interface + +**File:** `io.temporal.worker.WorkflowImplementationFactory` (new) + +A pluggable interface for creating workflow instances with different execution models: + +```java +/** + * Factory for creating workflow implementations. Different implementations + * can provide different execution models (e.g., thread-based, coroutine-based). + */ +public interface WorkflowImplementationFactory { + /** + * Returns true if this factory handles the given workflow implementation type. + * Used to route workflow registration to the appropriate factory. + */ + boolean supportsType(Class workflowImplementationType); + + /** + * Register workflow implementation types with this factory. + */ + void registerWorkflowImplementationTypes( + WorkflowImplementationOptions options, + Class... workflowImplementationTypes + ); + + /** + * Register a workflow implementation factory function. + */ + void addWorkflowImplementationFactory( + WorkflowImplementationOptions options, + Class workflowInterface, + Functions.Func factory + ); + + /** + * Create a ReplayWorkflow instance for the given workflow type. + * Returns null if this factory doesn't handle the given type. + */ + @Nullable + ReplayWorkflow createWorkflow( + WorkflowType workflowType, + WorkflowExecution workflowExecution + ); + + /** + * Returns the set of workflow types registered with this factory. + */ + Set getRegisteredWorkflowTypes(); +} +``` + +#### 2. Update `Worker` to Support Multiple Factories + +**File:** `io.temporal.worker.Worker` + +```java +public final class Worker { + // Default factory for Java workflows (existing behavior) + private final POJOWorkflowImplementationFactory defaultFactory; + + // Additional factories (e.g., for Kotlin coroutine workflows) + private final List additionalFactories = new ArrayList<>(); + + /** + * Register a custom workflow implementation factory. + * The factory will be consulted for workflow types it supports. + */ + public void registerWorkflowImplementationFactory(WorkflowImplementationFactory factory) { + additionalFactories.add(factory); + } + + // Existing method unchanged - uses default POJO factory + public void registerWorkflowImplementationTypes(Class... workflowImplementationTypes) { + defaultFactory.registerWorkflowImplementationTypes(options, workflowImplementationTypes); + } +} +``` + +#### 3. Update `SyncWorkflowWorker` to Use Factory Registry + +**File:** `io.temporal.internal.worker.SyncWorkflowWorker` + +Modify to consult all registered factories when creating workflow instances: + +```java +class CompositeReplayWorkflowFactory implements ReplayWorkflowFactory { + private final POJOWorkflowImplementationFactory defaultFactory; + private final List additionalFactories; + + @Override + public ReplayWorkflow getWorkflow(WorkflowType workflowType, WorkflowExecution execution) { + // First, check additional factories + for (WorkflowImplementationFactory factory : additionalFactories) { + ReplayWorkflow workflow = factory.createWorkflow(workflowType, execution); + if (workflow != null) { + return workflow; + } + } + // Fall back to default POJO factory + return defaultFactory.getWorkflow(workflowType, execution); + } + + @Override + public boolean isAnyTypeSupported() { + return defaultFactory.isAnyTypeSupported() || + additionalFactories.stream().anyMatch(f -> !f.getRegisteredWorkflowTypes().isEmpty()); + } +} +``` + +#### 4. Expose Required Internal Classes + +Some internal classes need to be accessible for custom `WorkflowImplementationFactory` implementations: + +**File:** `io.temporal.internal.replay.ReplayWorkflow` → Move to SPI package or make accessible + +```java +// Either move to io.temporal.worker.spi or document as semi-public API +public interface ReplayWorkflow { + void start(HistoryEvent event, ReplayWorkflowContext context); + boolean eventLoop() throws Throwable; + WorkflowExecutionResult getOutput(); + void cancel(String reason); + void close(); + // ... other methods +} +``` + +**File:** `io.temporal.internal.replay.ReplayWorkflowContext` → Similar treatment + +The exact approach (SPI package vs `@InternalApi` annotation) is discussed in [Open Question #6: SPI Stability](#6-spi-stability). + +#### 5. Add Async Version of `Workflow.await()` + +**File:** `io.temporal.workflow.Async` + +The existing `Workflow.await()` is blocking with no `Promise`-based equivalent. To support Kotlin coroutines, we need an async version in the `Async` class (consistent with `Async.function()`, `Async.procedure()`, etc.): + +```java +public final class Async { + // Existing methods + public static Promise function(Functions.Func func); + public static Promise procedure(Functions.Proc proc); + // ... + + // New async await methods for coroutine support + public static Promise await(Supplier condition); + public static Promise await(Duration timeout, Supplier condition); +} +``` + +This allows Kotlin to wrap it as a suspend function in `KWorkflow`: + +```kotlin +/** + * Suspends until the condition becomes true. + * + * This is the Kotlin equivalent of Java's [Workflow.await]. + * The condition is re-evaluated after each workflow event. + */ +suspend fun awaitCondition(condition: () -> Boolean) { + Async.await { condition() }.await() // Promise.await() suspends +} + +/** + * Suspends until the condition becomes true or timeout expires. + * + * This is the Kotlin equivalent of Java's [Workflow.await] with timeout. + * + * @return true if condition became true, false if timed out + */ +suspend fun awaitCondition(timeout: Duration, condition: () -> Boolean): Boolean { + return Async.await(timeout.toJava()) { condition() }.await() +} +``` + +**Implementation notes:** +- **Prerequisite:** `Async.await()` does not currently exist in the Java SDK and must be added before `KWorkflow.awaitCondition()` can be implemented. +- Under the hood, this uses `Async.await()` which returns a `Promise` that completes when the condition becomes true. The condition is re-evaluated after each workflow event (signals, activity completions, timers, etc.). +- The async version should integrate with the same condition-tracking mechanism used by the blocking `Workflow.await()`, completing the Promise when the condition becomes true or timeout expires. + +### Files Changed Summary + +| File | Change Type | Description | +|------|-------------|-------------| +| `WorkflowImplementationFactory.java` | **New** | Pluggable factory interface | +| `Worker.java` | **Modified** | Add `registerWorkflowImplementationFactory()` method | +| `SyncWorkflowWorker.java` | **Modified** | Use composite factory for workflow creation | +| `ReplayWorkflow.java` | **Modified** | Make accessible for SPI implementations | +| `ReplayWorkflowContext.java` | **Modified** | Make accessible for SPI implementations | +| `POJOWorkflowImplementationFactory.java` | **Modified** | Implement new interface | +| `Async.java` | **Modified** | Add `await()` methods returning `Promise` | + +### Backward Compatibility + +These changes maintain full backward compatibility: + +* `Worker.registerWorkflowImplementationTypes()` unchanged for existing users +* Existing Java workflows continue to work without modification +* New `registerWorkflowImplementationFactory()` is purely additive +* No Kotlin dependencies in `temporal-sdk` module +* No breaking changes to public APIs + +## Kotlin Module Implementation + +The `temporal-kotlin` module provides the coroutine-based implementation: + +```kotlin +// In temporal-kotlin module +class KotlinWorkflowImplementationFactory( + private val clientOptions: WorkflowClientOptions, + private val workerOptions: WorkerOptions, + private val cache: WorkflowExecutorCache +) : WorkflowImplementationFactory { + + private val registeredTypes = mutableMapOf() + + override fun supportsType(type: Class<*>): Boolean { + // Check if workflow methods are suspend functions (have Continuation parameter) + return type.methods.any { method -> + method.isAnnotationPresent(WorkflowMethod::class.java) && + method.parameterTypes.any { it.name == "kotlin.coroutines.Continuation" } + } + } + + override fun registerWorkflowImplementationTypes( + options: WorkflowImplementationOptions, + vararg types: Class<*> + ) { + for (type in types) { + require(supportsType(type)) { + "Class ${type.name} does not have suspend workflow methods" + } + val definition = KotlinWorkflowDefinition(type, options) + registeredTypes[definition.workflowType] = definition + } + } + + override fun createWorkflow( + workflowType: WorkflowType, + workflowExecution: WorkflowExecution + ): ReplayWorkflow? { + val definition = registeredTypes[workflowType.name] ?: return null + return KotlinReplayWorkflow( + definition, + workflowExecution, + KotlinCoroutineDispatcher() + ) + } + + override fun getRegisteredWorkflowTypes(): Set = registeredTypes.keys +} +``` + +```kotlin +// Kotlin extension for ergonomic registration +fun Worker.registerKotlinWorkflowImplementationTypes(vararg types: KClass<*>) { + val factory = KotlinWorkflowImplementationFactory(/* obtain from worker */) + for (type in types) { + factory.registerWorkflowImplementationTypes( + WorkflowImplementationOptions.getDefaultInstance(), + type.java + ) + } + registerWorkflowImplementationFactory(factory) +} +``` + +## KotlinCoroutineDispatcher + +The custom dispatcher ensures deterministic execution of coroutines and implements the `Delay` interface to intercept standard `kotlinx.coroutines.delay()` calls: + +* Executes coroutines in a controlled, deterministic order (FIFO queue) +* Integrates with Temporal's replay mechanism +* **Implements `Delay` interface** - standard `delay()` is automatically routed through Temporal timers +* Handles cancellation scopes properly +* All coroutines launched with this dispatcher inherit deterministic behavior + +```kotlin +internal class KotlinCoroutineDispatcher( + private val workflowContext: KotlinWorkflowContext +) : CoroutineDispatcher(), Delay { + + private val readyQueue = ArrayDeque() + private var inEventLoop = false + + override fun dispatch(context: CoroutineContext, block: Runnable) { + // Queue for deterministic execution + readyQueue.addLast(block) + + // If we're already in the event loop, the block will be picked up + // Otherwise, we need to signal the workflow to continue + if (!inEventLoop) { + workflowContext.signalReady() + } + } + + /** + * Intercept standard delay() calls and route through Temporal timer. + * This allows users to write `delay(5.seconds)` and have it work deterministically. + */ + override fun scheduleResumeAfterDelay( + timeMillis: Long, + continuation: CancellableContinuation + ) { + workflowContext.scheduleTimer(timeMillis) { + continuation.resume(Unit) + } + } + + /** + * Process queued coroutines deterministically. + * Called by the replay machinery during workflow task processing. + */ + fun eventLoop(deadlockDetectionTimeout: Long): Boolean { + inEventLoop = true + try { + val deadline = System.currentTimeMillis() + deadlockDetectionTimeout + + while (readyQueue.isNotEmpty()) { + if (System.currentTimeMillis() > deadline) { + throw PotentialDeadlockException("Workflow thread stuck") + } + + val runnable = readyQueue.removeFirst() + runnable.run() + } + + return workflowContext.hasMoreWork() + } finally { + inEventLoop = false + } + } +} +``` + +### Delay Implementation + +The `Delay` interface implementation is integrated directly into `KotlinCoroutineDispatcher` (shown above). This allows standard `kotlinx.coroutines.delay()` to work deterministically: + +```kotlin +// Users write standard Kotlin: +delay(5.seconds) + +// The dispatcher's scheduleResumeAfterDelay is called automatically, +// which routes through Temporal's deterministic timer mechanism +``` + +**Why this works:** +- When a coroutine calls `delay()`, kotlinx.coroutines checks if the dispatcher implements `Delay` +- If it does, `scheduleResumeAfterDelay` is called instead of blocking +- Our implementation schedules a Temporal timer that resumes the continuation when fired +- This is completely transparent to user code - no custom `KWorkflow.delay()` needed + +## KotlinReplayWorkflow + +Implements the `ReplayWorkflow` interface using coroutines: + +```kotlin +internal class KotlinReplayWorkflow( + private val definition: KotlinWorkflowDefinition, + private val workflowExecution: WorkflowExecution, + private val dispatcher: KotlinCoroutineDispatcher +) : ReplayWorkflow { + + private var workflowJob: Job? = null + private var result: WorkflowExecutionResult? = null + private lateinit var context: KotlinWorkflowContext + + override fun start(event: HistoryEvent, replayContext: ReplayWorkflowContext) { + context = KotlinWorkflowContext(replayContext) + + // Create coroutine scope with our deterministic dispatcher + val scope = CoroutineScope( + dispatcher + + KotlinDelay(context) + + SupervisorJob() + ) + + // Start the workflow coroutine + workflowJob = scope.launch { + try { + val instance = definition.createInstance() + val output = definition.invokeWorkflowMethod(instance, event.arguments) + result = WorkflowExecutionResult.completed(output) + } catch (e: CancellationException) { + result = WorkflowExecutionResult.canceled(e.message) + } catch (e: Throwable) { + result = WorkflowExecutionResult.failed(e) + } + } + } + + override fun eventLoop(): Boolean { + return dispatcher.eventLoop(deadlockDetectionTimeout) + } + + override fun getOutput(): WorkflowExecutionResult? = result + + override fun cancel(reason: String) { + workflowJob?.cancel(CancellationException(reason)) + } + + override fun close() { + workflowJob?.cancel() + } +} +``` + +## KotlinWorkflowContext + +Provides workflow APIs to the coroutine-based workflow: + +```kotlin +internal class KotlinWorkflowContext( + private val replayContext: ReplayWorkflowContext +) { + fun createTimer(duration: Duration): CompletableFuture { + return replayContext.createTimer(duration) + } + + fun executeActivity( + name: String, + options: ActivityOptions, + args: Array + ): CompletableFuture { + return replayContext.executeActivity(name, options, args) + } + + fun executeChildWorkflow( + type: String, + options: ChildWorkflowOptions, + args: Array + ): CompletableFuture { + return replayContext.executeChildWorkflow(type, options, args) + } + + fun sideEffect(func: () -> Any?): Any? { + return replayContext.sideEffect(func) + } + + fun getVersion(changeId: String, minSupported: Int, maxSupported: Int): Int { + return replayContext.getVersion(changeId, minSupported, maxSupported) + } + + fun currentTime(): Instant = replayContext.currentTimeMillis().let { + Instant.ofEpochMilli(it) + } + + // ... other workflow APIs +} +``` + +## Activity Execution Implementation + +### String-based Activity Execution + +String-based activity execution is implemented directly in `KWorkflow` using **reified generics** to provide a clean API without requiring explicit `Class<*>` parameters or casts: + +```kotlin +object KWorkflow { + /** + * Execute an activity by name with reified return type. + * + * Usage: KWorkflow.executeActivity("activityName", options, arg1, arg2) + * + * The `inline` + `reified` combination allows access to R::class.java at runtime. + * At each call site, the compiler substitutes the actual type. + */ + inline suspend fun executeActivity( + activityName: String, + options: ActivityOptions, + vararg args: Any? + ): R { + return executeActivityInternal(activityName, R::class.java, options, args) as R + } + + /** + * Internal implementation that receives the actual Class for DataConverter. + */ + @PublishedApi + internal suspend fun executeActivityInternal( + activityName: String, + resultType: Class<*>, + options: ActivityOptions, + args: Array + ): Any? { + val context = currentContext() + val future = context.executeActivity(activityName, resultType, options, args) + return future.await() // Suspends until activity completes + } +} +``` + +**Parallel Execution:** Instead of a custom `startActivity` method returning a handle, users use standard Kotlin patterns: + +```kotlin +// Parallel activities using standard coroutineScope { async { } } +val results = coroutineScope { + val d1 = async { KWorkflow.executeActivity("add", options, 1, 2) } + val d2 = async { KWorkflow.executeActivity("add", options, 3, 4) } + awaitAll(d1, d2) // Standard Deferred instances +} +``` + +This approach uses standard `Deferred` instead of a custom `KActivityHandle`, following the design principle of using idiomatic Kotlin patterns. + +**Why `inline` + `reified`?** + +Kotlin generics are erased at runtime (like Java). Without `reified`, we cannot access `R::class.java`: + +```kotlin +// Does NOT work - R is erased at runtime +suspend fun executeActivity(activityName: String, options: ActivityOptions, vararg args: Any?): R { + val resultType = R::class.java // Compile error: Cannot use 'R' as reified type parameter +} + +// WORKS - inline + reified captures type at compile time +inline suspend fun executeActivity(activityName: String, options: ActivityOptions, vararg args: Any?): R { + val resultType = R::class.java // OK: compiler substitutes actual type at call site +} +``` + +**How it works at the call site:** + +```kotlin +// User writes: +val result = KWorkflow.executeActivity("greet", options, name) + +// Compiler inlines to (conceptually): +val result = KWorkflow.executeActivityInternal("greet", String::class.java, options, arrayOf(name)) as String +``` + +**Notes:** +- `@PublishedApi` is required for `internal` functions called from `inline` functions +- `inline suspend fun` is supported since Kotlin 1.3.70 +- Cannot be overridden (inline functions are not virtual) +- Cannot be called from Java (inlined at Kotlin call sites only) + +### Return Type for DataConverter + +The DataConverter needs the return type to deserialize the activity result. With `reified` generics, we can capture the type at compile time: + +```kotlin +inline suspend fun executeActivity(...): R { + // Simple approach - works for non-generic types + val resultType = R::class.java // String::class.java, Int::class.java, etc. + + // Full generic type info using typeOf (Kotlin 1.6+) + val kType: KType = typeOf() // Preserves List, Map, etc. +} +``` + +**Limitation with `R::class.java`:** + +```kotlin +// Works fine for simple types +executeActivity("greet", options, name) // R::class.java = String::class.java + +// Loses type parameter for generic types +executeActivity>("getNames", options) // R::class.java = List::class.java (loses String) +``` + +**Solution using `typeOf()`:** + +Kotlin's `typeOf()` (introduced in Kotlin 1.6) preserves full generic type information: + +```kotlin +inline suspend fun executeActivity( + activityName: String, + options: ActivityOptions, + vararg args: Any? +): R { + val kType: KType = typeOf() // Preserves full generic info + val javaType = kType.javaType // Converts to java.lang.reflect.Type + + return executeActivityInternal(activityName, javaType, options, args) as R +} + +// Internal method accepts java.lang.reflect.Type (handles ParameterizedType) +@PublishedApi +internal suspend fun executeActivityInternal( + activityName: String, + resultType: java.lang.reflect.Type, // Can be Class or ParameterizedType + options: ActivityOptions, + args: Array +): Any? { + val context = currentContext() + val future = context.executeActivity(activityName, resultType, options, args) + return future.await() +} +``` + +This allows the DataConverter to properly deserialize generic types: + +```kotlin +// Now works correctly +val names: List = KWorkflow.executeActivity>("getNames", options) +val mapping: Map = KWorkflow.executeActivity>("getMapping", options) +``` + +### CompletableFuture to Suspend Extension + +```kotlin +// Extension to convert CompletableFuture to suspend +private suspend fun CompletableFuture.await(): T { + return suspendCancellableCoroutine { cont -> + this.whenComplete { result, error -> + if (error != null) { + cont.resumeWithException(error) + } else { + cont.resume(result) + } + } + cont.invokeOnCancellation { + this.cancel(true) + } + } +} +``` + +### Method Reference-based Activity Execution (Phase 2) + +The typed activity API uses `KFunction` references to extract metadata (interface class, method name) without requiring proxy creation: + +```kotlin +object KWorkflow { + /** + * Execute activity using method reference. + * Uses KFunction to extract activity name and return type. + */ + suspend fun executeActivity( + activity: KFunction2, + options: ActivityOptions, + arg1: A1 + ): R { + val activityName = extractActivityName(activity) + val resultType = extractReturnType(activity) + val context = currentContext() + val future = context.executeActivity(activityName, resultType, options, arrayOf(arg1)) + return future.await() as R + } + + /** + * Extract activity name from KFunction. + * Uses @ActivityMethod annotation name if present, otherwise method name. + */ + private fun extractActivityName(function: KFunction<*>): String { + val method = function.javaMethod + ?: throw IllegalArgumentException("Cannot get Java method from function") + + val annotation = method.getAnnotation(ActivityMethod::class.java) + return if (annotation != null && annotation.name.isNotEmpty()) { + annotation.name + } else { + method.name + } + } + + /** + * Extract return type from KFunction for DataConverter. + */ + private fun extractReturnType(function: KFunction<*>): Class<*> { + val method = function.javaMethod + ?: throw IllegalArgumentException("Cannot get Java method from function") + return method.returnType + } +} +``` + +**How `KFunction` works:** + +```kotlin +// User writes: +val result = KWorkflow.executeActivity( + GreetingActivities::composeGreeting, // KFunction2 + options, + name +) + +// At runtime: +// - activity.javaMethod gives us the Method object +// - Method.name gives us "composeGreeting" +// - Method.declaringClass gives us GreetingActivities +// - Method.returnType gives us String.class +``` + +## Duration Extensions + +```kotlin +// DurationExt.kt +package io.temporal.kotlin + +import java.time.Duration as JavaDuration +import kotlin.time.Duration as KotlinDuration +import kotlin.time.toJavaDuration +import kotlin.time.toKotlinDuration + +/** + * Convert Kotlin Duration to Java Duration for interop with Java SDK. + */ +fun KotlinDuration.toJava(): JavaDuration = this.toJavaDuration() + +/** + * Convert Java Duration to Kotlin Duration. + */ +fun JavaDuration.toKotlin(): KotlinDuration = this.toKotlinDuration() + +/** + * DSL property that accepts Kotlin Duration and converts to Java Duration. + */ +var ActivityOptions.Builder.startToCloseTimeout: KotlinDuration + @Deprecated("Write-only property", level = DeprecationLevel.HIDDEN) + get() = throw UnsupportedOperationException() + set(value) { setStartToCloseTimeout(value.toJava()) } + +// Similar extensions for other timeout properties... +``` + +## Decision Justifications + +### Why Coroutines Instead of Extending Java Thread Model? + +* **Idiomatic Kotlin**: Coroutines are the standard concurrency model in Kotlin +* **Structured concurrency**: `coroutineScope`, `async`, `launch` provide clear parent-child relationships +* **Cancellation**: Coroutine cancellation maps naturally to Temporal's cancellation scopes +* **Performance**: Lightweight compared to threads, though this is less relevant in workflow context +* **Ecosystem**: Integrates with Kotlin ecosystem (Flow, channels, etc.) + +### Why Suspend Functions for Workflows? + +* **Natural async**: Workflow operations (activities, timers) are inherently async +* **Sequential code**: Suspend functions allow sequential-looking code for async operations +* **Error handling**: Standard try-catch works with suspend functions +* **Testing**: Coroutine test utilities work well with suspend functions + +### Why Keep Compatibility with Java SDK? + +* **Gradual migration**: Teams can adopt Kotlin incrementally +* **Ecosystem leverage**: Benefits from Java SDK's stability and features +* **Reduced maintenance**: Shared infrastructure reduces duplication +* **Activity reuse**: Existing Java activities work without modification + +### Why Untyped Stubs First in Phase 1? + +* **Complexity management**: Typed stub generation requires more infrastructure +* **Proof of concept**: Validates coroutine-based execution model first +* **Faster iteration**: Can release usable SDK sooner +* **Java interop**: Untyped stubs work with any activity implementation + +### Why Custom CoroutineDispatcher? + +* **Determinism**: Standard dispatchers are non-deterministic +* **Replay support**: Must execute coroutines in same order during replay +* **Timer integration**: `delay()` must map to Temporal timers, not actual time +* **Deadlock detection**: Can implement workflow-aware deadlock detection + +### Why Unified Worker Instead of Separate KotlinWorkerFactory? + +* **Per-instance execution**: The dispatcher is per workflow instance, not per worker—there's no technical reason to separate workers +* **Simpler API**: One worker type is easier to understand and use +* **Mixed task queues**: Java and Kotlin workflows can share the same task queue +* **Easier migration**: Convert workflows one at a time without changing infrastructure +* **No Kotlin in Java SDK**: The pluggable `WorkflowImplementationFactory` keeps all Kotlin code in `temporal-kotlin` + +### Why Invest in Kotlin Idioms? + +* **Developer expectations**: Kotlin developers expect null safety and kotlin.time.Duration +* **Null safety**: Nullable types replace `Optional` throughout the API +* **Readability**: `30.seconds` is more readable than `Duration.ofSeconds(30)` +* **Coroutines**: `suspend fun` for natural async workflows and activities +* **IDE support**: Kotlin idioms provide better autocomplete and error detection +* **Differentiation**: Makes the Kotlin SDK feel native, not just a wrapper around Java + +## Open Questions + +### Workflow Versioning with Coroutines + +How do `Workflow.getVersion()` semantics work with coroutines? + +- **Version marker ordering**: Version checks record markers in the event history. With coroutines suspending and resuming, need to ensure markers are recorded in deterministic order during replay. +- **Suspension points**: If `getVersion()` is called before a suspension point, does the version marker position remain stable across code changes? +- **Expected behavior**: Likely works the same as Java since version markers are recorded synchronously before any suspension. Needs validation during implementation. + +## Resolved Decisions + +### CancellationScope Mapping + +Kotlin's built-in coroutine cancellation replaces Java SDK's `CancellationScope`. The `KotlinCoroutineDispatcher` already provides deterministic execution, so standard Kotlin cancellation patterns work correctly with Temporal's replay mechanism. + +| Java SDK | Kotlin SDK | Notes | +|----------|------------|-------| +| `Workflow.newCancellationScope(() -> {...})` | `coroutineScope { ... }` | Parent cancellation propagates to children | +| `Workflow.newDetachedCancellationScope(() -> {...})` | `withContext(NonCancellable) { ... }` | For cleanup code that must run | +| `CancellationScope.cancel()` | `job.cancel()` | Explicit cancellation | +| `CancellationScope.isCancelRequested()` | `!isActive` | Check without throwing | +| `CancellationScope.throwCanceled()` | `ensureActive()` | Throws if cancelled | +| `scope.run()` with timeout | `withTimeout(duration) { ... }` | Built-in timeout support | + +**Why this works:** +- Server cancellation triggers coroutine cancellation via root `Job` +- Cancellation state is recorded in workflow history for deterministic replay +- `withContext(NonCancellable)` maps directly to detached scope semantics +- No custom API needed—Kotlin's structured concurrency matches Temporal's model + +### DSL vs Annotations + +**Decision**: Keep annotations (`@WorkflowMethod`, `@SignalMethod`) as the primary approach for consistency with Java SDK. Consider DSL as optional alternative in future phases if there's demand. + +### SPI Stability + +**Decision**: Start with `@InternalApi` annotation for Phase 1. The affected interfaces (`WorkflowImplementationFactory`, `ReplayWorkflow`, `ReplayWorkflowContext`) will be promoted to a stable SPI package once they stabilize after real-world usage (likely post-Phase 3). + +## Future Considerations + +### Kotlin Flow Support + +Streaming results via Kotlin Flow is deferred to post-Phase 3. Existing patterns (heartbeat details, signals) cover most use cases. Flow integration requires careful handling of backpressure, cancellation, and replay determinism. + +### Kotlin Multiplatform + +Not a near-term priority. The SDK is JVM-only due to Java SDK dependency. May revisit if sdk-core provides C bindings that Kotlin/Native could use. + +## References + +* [Kotlin Coroutines Prototype PR #1792](https://github.com/temporalio/sdk-java/pull/1792) - Early prototype; this proposal uses a different architecture (pluggable factory vs separate worker factory) +* [Existing temporal-kotlin Module](https://github.com/temporalio/sdk-java/tree/master/temporal-kotlin) +* [.NET SDK Proposal - Phase 1](../dotnet/sdk-phase-1.md) +* [.NET SDK Proposal - Phase 2](../dotnet/sdk-phase-2.md) diff --git a/kotlin/implementation/sdk-proposal.md b/kotlin/implementation/sdk-proposal.md new file mode 100644 index 0000000..95e61a2 --- /dev/null +++ b/kotlin/implementation/sdk-proposal.md @@ -0,0 +1,53 @@ +# Kotlin SDK Proposal + +The Kotlin SDK proposal is split into two documents: + +## Design Principle + +**Use idiomatic Kotlin language patterns wherever possible instead of custom APIs.** + +The Kotlin SDK should feel natural to Kotlin developers by leveraging standard `kotlinx.coroutines` primitives. Custom APIs should only be introduced when Temporal-specific semantics cannot be achieved through standard patterns. + +| Pattern | Standard Kotlin | Temporal Integration | +|---------|-----------------|----------------------| +| Parallel execution | `coroutineScope { async { ... } }` | Works via deterministic dispatcher | +| Await multiple | `awaitAll(d1, d2)` | Standard kotlinx.coroutines | +| Sleep/delay | `delay(duration)` | Intercepted via `Delay` interface | +| Deferred results | `Deferred` | Standard + `Promise.toDeferred()` | + +## [SDK API](../README.md) + +Public API and developer experience documentation including: + +- Design principle: idiomatic Kotlin patterns +- Kotlin idioms (Duration, null safety, standard coroutines) +- Workflow definition with suspend functions +- Activity definition (no stubs - options per call) +- Client API (leverages existing DSL extensions) +- Worker API +- Data conversion +- Interceptors +- Testing +- Migration guide from Java SDK +- Complete examples + +## [SDK Implementation](./sdk-implementation.md) + +Internal architecture and implementation details including: + +- Relationship to Java SDK +- Repository and package strategy +- Existing DSL extensions (ActivityOptions, WorkflowOptions, etc.) +- New classes (KWorkflow, KotlinCoroutineDispatcher, etc.) +- Unified worker architecture +- Java SDK refactoring for pluggability +- `WorkflowImplementationFactory` interface +- `KotlinCoroutineDispatcher` with `Delay` implementation +- `KotlinReplayWorkflow` implementation +- Decision justifications +- Open questions + +## Quick Links + +- [Kotlin Coroutines Prototype PR #1792](https://github.com/temporalio/sdk-java/pull/1792) +- [Existing temporal-kotlin Module](https://github.com/temporalio/sdk-java/tree/master/temporal-kotlin) diff --git a/kotlin/implementation/suspend-activities-design.md b/kotlin/implementation/suspend-activities-design.md new file mode 100644 index 0000000..5edd82d --- /dev/null +++ b/kotlin/implementation/suspend-activities-design.md @@ -0,0 +1,996 @@ +# Design: Suspend Function Support for Kotlin Activities (Revised) + +## Overview + +This document proposes adding support for Kotlin suspend functions as activity implementations. This enables activities to use Kotlin's coroutine-based async I/O libraries (Ktor, R2DBC, etc.) without blocking threads. + +## Current State + +### Activity Execution Model + +Activities in the Java SDK are **thread-based**: + +``` +Worker Thread Pool + ↓ +ActivityTaskHandlerImpl.handle() + ↓ +POJOActivityTaskExecutor.execute() + ↓ +Method.invoke() [blocking] + ↓ +Return result → sendReply() [blocking gRPC] + ↓ +Release slot permit +``` + +- Activities run on dedicated threads from a thread pool +- Context is stored in thread-local storage (`ActivityInternal.currentActivityExecutionContext`) +- Activities can block the thread (e.g., HTTP calls, database queries) +- Result is sent to server via **blocking** gRPC call + +### Existing Async Pattern: Local Manual Completion + +The Java SDK already supports async activity completion via `useLocalManualCompletion()`: + +```java +public int execute() { + ActivityExecutionContext context = Activity.getExecutionContext(); + ManualActivityCompletionClient client = context.useLocalManualCompletion(); + + // Offload to thread pool - activity method returns immediately + ForkJoinPool.commonPool().execute(() -> { + try { + // Async work happens here + Object result = doAsyncWork(); + client.complete(result); // Complete when done + } catch (Exception e) { + client.fail(e); // Or fail on error + } + }); + + return 0; // Return immediately, thread is freed +} +``` + +**Key properties:** +- `useLocalManualCompletion()` marks activity for manual completion +- Returns `ManualActivityCompletionClient` for completing later +- Respects `maxConcurrentActivityExecutionSize` limit (slot stays reserved) +- Releases slot permit when `complete()`, `fail()`, or `reportCancellation()` is called + +### Problem + +Using `runBlocking` in a suspend activity wrapper defeats the purpose - it still ties up the thread pool thread during the entire coroutine execution. We need truly non-blocking execution. + +## Proposed Design + +### Key Insight + +Leverage the existing `useLocalManualCompletion()` pattern to achieve truly async suspend activities without modifying the core Java SDK. + +### Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Activity Task Arrives │ +└───────────────────────────────┬─────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ SuspendActivityTaskExecutor │ +│ │ +│ 1. Get ManualActivityCompletionClient via useLocalManualCompletion()│ +│ 2. Launch coroutine on CoroutineDispatcher │ +│ 3. Return immediately (thread freed) │ +└───────────────────────────────┬─────────────────────────────────────┘ + │ + ┌───────────────────────┴───────────────────────┐ + │ │ + ▼ ▼ +┌─────────────────────┐ ┌─────────────────────────┐ +│ Thread Pool Thread │ │ Coroutine Dispatcher │ +│ (freed immediately) │ │ │ +└─────────────────────┘ │ Execute suspend fun │ + │ │ │ + │ ▼ │ + │ On I/O suspend: │ + │ Thread released! │ + │ │ │ + │ ▼ │ + │ Resume on I/O complete │ + │ │ │ + │ ▼ │ + │ Complete via client │ + └─────────────────────────┘ + │ + ▼ + ┌─────────────────────────┐ + │ ManualActivityCompletion│ + │ Client.complete(result) │ + │ │ + │ - Sends result to server│ + │ - Releases slot permit │ + └─────────────────────────┘ +``` + +### Component 1: SuspendActivityInvoker + +A wrapper that intercepts suspend activity method invocations: + +```kotlin +// io.temporal.kotlin.internal.SuspendActivityInvoker + +internal class SuspendActivityInvoker( + private val activityInstance: Any, + private val method: KFunction<*>, + private val dispatcher: CoroutineDispatcher, + private val heartbeatInterval: Duration? = null // Optional auto-heartbeat +) { + /** + * Called by the activity framework. Returns immediately after launching coroutine. + */ + fun invoke( + context: ActivityExecutionContext, + args: Array + ): Any? { + // Get manual completion client - marks activity for async completion + val completionClient = context.useLocalManualCompletion() + + // Create cancellation-aware scope + val job = SupervisorJob() + val scope = CoroutineScope(dispatcher + job) + + // Launch coroutine - this returns immediately! + scope.launch { + // Create activity context element for coroutine + val kContext = KActivityExecutionContext(context, completionClient, job) + + withContext(KActivityExecutionContextElement(kContext)) { + try { + // Start auto-heartbeat if configured + val heartbeatJob = heartbeatInterval?.let { interval -> + launch { autoHeartbeat(completionClient, interval, job) } + } + + // Execute the suspend function + val result = method.callSuspend(activityInstance, *args) + + // Cancel heartbeat job + heartbeatJob?.cancel() + + // Complete successfully (uses Dispatchers.IO for blocking gRPC) + withContext(Dispatchers.IO) { + completionClient.complete(result) + } + } catch (e: CancellationException) { + // Coroutine was cancelled (activity cancellation or timeout) + withContext(Dispatchers.IO + NonCancellable) { + completionClient.reportCancellation(null) + } + } catch (e: Throwable) { + // Activity failed + withContext(Dispatchers.IO + NonCancellable) { + completionClient.fail(e) + } + } + } + } + + // Return immediately - actual result sent via completion client + return null + } + + /** + * Background job that sends heartbeats and monitors for cancellation. + */ + private suspend fun autoHeartbeat( + client: ManualActivityCompletionClient, + interval: Duration, + parentJob: Job + ) { + while (isActive) { + delay(interval) + try { + // recordHeartbeat throws CanceledFailure if activity is cancelled + withContext(Dispatchers.IO) { + client.recordHeartbeat(null) + } + } catch (e: CanceledFailure) { + // Activity was cancelled - cancel the parent job + parentJob.cancel(CancellationException("Activity cancelled", e)) + break + } + } + } +} +``` + +### Component 2: KActivityExecutionContext (Coroutine-aware) + +Activity context accessible from within coroutines: + +```kotlin +// io.temporal.kotlin.internal.KActivityExecutionContext + +internal class KActivityExecutionContext( + private val javaContext: ActivityExecutionContext, + private val completionClient: ManualActivityCompletionClient, + private val parentJob: Job +) { + val info: ActivityInfo get() = javaContext.info + val taskToken: ByteArray get() = javaContext.taskToken + + /** + * Sends a heartbeat. This is a suspend function that doesn't block. + * Throws CancellationException if the activity has been cancelled. + */ + suspend fun heartbeat(details: Any? = null) { + try { + withContext(Dispatchers.IO) { + completionClient.recordHeartbeat(details) + } + } catch (e: CanceledFailure) { + // Convert to coroutine cancellation + parentJob.cancel(CancellationException("Activity cancelled", e)) + throw CancellationException("Activity cancelled", e) + } + } + + /** + * Gets heartbeat details from a previous attempt. + */ + inline fun getHeartbeatDetails(): T? { + return javaContext.getHeartbeatDetails(T::class.java).orElse(null) + } +} + +// Coroutine context element +internal class KActivityExecutionContextElement( + val context: KActivityExecutionContext +) : AbstractCoroutineContextElement(Key) { + companion object Key : CoroutineContext.Key +} +``` + +### Component 3: KActivity Public API + +```kotlin +// io.temporal.kotlin.activity.KActivity + +public object KActivity { + + /** + * Gets activity info for the current execution. + * Works in both suspend and regular activities. + */ + public fun getInfo(): KActivityInfo { + // Try coroutine context first (suspend activities) + val kContext = currentCoroutineContextOrNull()?.get(KActivityExecutionContextElement)?.context + if (kContext != null) { + return KActivityInfo(kContext.info) + } + // Fall back to thread-local (regular activities) + return KActivityInfo(Activity.getExecutionContext().info) + } + + /** + * Sends a heartbeat with optional details. + * In suspend activities, this is non-blocking. + * + * @throws CancellationException if the activity has been cancelled + */ + public suspend fun heartbeat(details: T? = null) { + val kContext = coroutineContext[KActivityExecutionContextElement]?.context + ?: throw IllegalStateException("heartbeat() must be called from within an activity") + kContext.heartbeat(details) + } + + /** + * Gets heartbeat details from a previous attempt. + */ + public inline fun getHeartbeatDetails(): T? { + val kContext = currentCoroutineContextOrNull()?.get(KActivityExecutionContextElement)?.context + if (kContext != null) { + return kContext.getHeartbeatDetails() + } + return Activity.getExecutionContext() + .getHeartbeatDetails(T::class.java) + .orElse(null) + } + + /** + * Gets the task token for external completion scenarios. + */ + public fun getTaskToken(): ByteArray { + val kContext = currentCoroutineContextOrNull()?.get(KActivityExecutionContextElement)?.context + if (kContext != null) { + return kContext.taskToken + } + return Activity.getExecutionContext().taskToken + } +} + +// Helper to get coroutine context without throwing +private fun currentCoroutineContextOrNull(): CoroutineContext? { + return try { + // This works if we're in a coroutine + runBlocking { coroutineContext } + } catch (e: Exception) { + null + } +} +``` + +### Component 4: Activity Registration + +Detect suspend functions at registration and create appropriate invokers: + +```kotlin +// io.temporal.kotlin.worker.KWorkerFactory (enhanced) + +public class KWorkerFactory(client: KWorkflowClient) { + + private val activityDispatcher: CoroutineDispatcher = Dispatchers.Default + private val defaultHeartbeatInterval: Duration? = null // Optional + + /** + * Configure the dispatcher for suspend activities. + */ + public fun setActivityDispatcher(dispatcher: CoroutineDispatcher) { + this.activityDispatcher = dispatcher + } + + /** + * Register activity implementations. + * Automatically detects suspend functions and creates appropriate handlers. + */ + public fun registerActivitiesImplementations(vararg activityImplementations: Any) { + for (impl in activityImplementations) { + val activityInterfaces = findActivityInterfaces(impl::class) + + for (iface in activityInterfaces) { + for (method in iface.memberFunctions) { + if (isActivityMethod(method)) { + if (method.isSuspend) { + // Create suspend-aware invoker + val invoker = SuspendActivityInvoker( + activityInstance = impl, + method = method, + dispatcher = activityDispatcher, + heartbeatInterval = defaultHeartbeatInterval + ) + registerSuspendActivity(method, invoker) + } else { + // Use existing POJO registration + registerPOJOActivity(method, impl) + } + } + } + } + } + } + + private fun registerSuspendActivity(method: KFunction<*>, invoker: SuspendActivityInvoker) { + // Create a wrapper that the Java SDK can invoke + val wrapper = object : ActivityMethod { + fun execute(context: ActivityExecutionContext, args: Array): Any? { + return invoker.invoke(context, args) + } + } + // Register with Java SDK's activity registry + // ... registration logic + } +} +``` + +## Thread/Coroutine Flow Diagram + +``` +Timeline: +─────────────────────────────────────────────────────────────────────────────► + +Thread Pool Thread #1: +│ +├─ Poll for task ───────────────────────────────────────────────────────────── +│ │ +│ ├─ Receive activity task +│ │ │ +│ │ ├─ Call SuspendActivityInvoker.invoke() +│ │ │ │ +│ │ │ ├─ Get ManualActivityCompletionClient +│ │ │ ├─ Launch coroutine (async!) +│ │ │ └─ Return immediately +│ │ │ +│ │ └─ Thread freed! ◄───────────────────── +│ │ +│ └─ Poll for next task ──────────────────────────────────────────────────── + +Coroutine on Dispatcher: + │ + ├─ Start executing suspend fun + │ │ + │ ├─ Await HTTP call (suspend) + │ │ │ + │ │ └─ Thread released while waiting + │ │ + │ ├─ Resume on HTTP response + │ │ │ + │ │ └─ Continue processing + │ │ + │ └─ Return result + │ + ├─ Call client.complete(result) + │ │ + │ └─ gRPC to server (on Dispatchers.IO) + │ + └─ Slot permit released +``` + +## Usage Examples + +### Example 1: Basic Suspend Activity + +```kotlin +@ActivityInterface +interface HttpActivities { + suspend fun fetchUser(userId: String): User +} + +class HttpActivitiesImpl( + private val client: HttpClient // Ktor client +) : HttpActivities { + + override suspend fun fetchUser(userId: String): User { + // Non-blocking HTTP call - thread is released during I/O wait + return client.get("https://api.example.com/users/$userId").body() + } +} +``` + +### Example 2: Activity with Heartbeat + +```kotlin +@ActivityInterface +interface BatchActivities { + suspend fun processBatch(items: List): BatchResult +} + +class BatchActivitiesImpl : BatchActivities { + + override suspend fun processBatch(items: List): BatchResult { + val results = mutableListOf() + + for ((index, item) in items.withIndex()) { + // Process item asynchronously + val result = processItem(item) + results.add(result) + + // Non-blocking heartbeat - throws CancellationException if cancelled + KActivity.heartbeat(Progress(index + 1, items.size)) + } + + return BatchResult(results) + } + + private suspend fun processItem(item: Item): ItemResult { + // Async processing using Ktor, R2DBC, etc. + delay(100) // Simulated async work + return ItemResult(item.id, "processed") + } +} +``` + +### Example 3: Mixed Activities (Suspend and Regular) + +```kotlin +@ActivityInterface +interface MixedActivities { + // Regular activity - runs on thread pool, blocks during execution + fun computeHash(data: ByteArray): String + + // Suspend activity - thread freed during I/O + suspend fun fetchRemoteData(url: String): ByteArray +} + +class MixedActivitiesImpl : MixedActivities { + + // CPU-bound work - regular function is fine (uses thread pool) + override fun computeHash(data: ByteArray): String { + return MessageDigest.getInstance("SHA-256") + .digest(data) + .toHexString() + } + + // I/O-bound work - suspend function for efficiency + override suspend fun fetchRemoteData(url: String): ByteArray { + return httpClient.get(url).body() + } +} +``` + +### Example 4: Database Activity with R2DBC + +```kotlin +@ActivityInterface +interface DatabaseActivities { + suspend fun findUser(id: Long): User? + suspend fun saveUser(user: User): Long +} + +class DatabaseActivitiesImpl( + private val connectionFactory: ConnectionFactory // R2DBC +) : DatabaseActivities { + + override suspend fun findUser(id: Long): User? { + return connectionFactory.create().awaitSingle().use { conn -> + conn.createStatement("SELECT * FROM users WHERE id = $1") + .bind("$1", id) + .execute() + .awaitFirst() + .map { row -> row.toUser() } + .awaitFirstOrNull() + } + } + + override suspend fun saveUser(user: User): Long { + return connectionFactory.create().awaitSingle().use { conn -> + conn.createStatement( + "INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id" + ) + .bind("$1", user.name) + .bind("$2", user.email) + .execute() + .awaitFirst() + .map { row -> row.get("id", Long::class.java)!! } + .awaitFirst() + } + } +} +``` + +### Example 5: Parallel Async Operations + +```kotlin +@ActivityInterface +interface AggregatorActivities { + suspend fun aggregateData(sources: List): AggregatedResult +} + +class AggregatorActivitiesImpl : AggregatorActivities { + + override suspend fun aggregateData(sources: List): AggregatedResult { + // Fetch from all sources in parallel - truly concurrent! + val results = coroutineScope { + sources.map { source -> + async { + fetchFromSource(source) + } + }.awaitAll() + } + + return AggregatedResult(results) + } + + private suspend fun fetchFromSource(source: String): SourceData { + return httpClient.get(source).body() + } +} +``` + +## Cancellation Handling + +### Cancellation Flow + +``` +Server sends cancellation + │ + ▼ +┌─────────────────────────────────────┐ +│ Heartbeat throws CanceledFailure │ +│ (either auto-heartbeat or manual) │ +└───────────────────┬─────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ parentJob.cancel() called │ +│ - Cancels main coroutine │ +│ - Cancels all child coroutines │ +└───────────────────┬─────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ CancellationException propagates │ +│ through coroutine hierarchy │ +└───────────────────┬─────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ Caught in SuspendActivityInvoker │ +│ - Reports cancellation to server │ +│ - Releases slot permit │ +└─────────────────────────────────────┘ +``` + +### Cancellation Detection Options + +1. **Auto-heartbeat** (recommended for long-running activities): + - Configure heartbeat interval at registration + - Background job periodically heartbeats + - Cancellation detected within heartbeat interval + +2. **Manual heartbeat**: + - Activity explicitly calls `KActivity.heartbeat()` + - Cancellation detected at heartbeat points + +3. **Polling check** (for tight loops): + ```kotlin + suspend fun processItems(items: List) { + for (item in items) { + ensureActive() // Check for cancellation + process(item) + } + } + ``` + +## Coroutine Dispatcher: Thread Model + +### Where Do Coroutine Threads Come From? + +When a suspend activity is invoked, the coroutine runs on threads provided by a `CoroutineDispatcher`. +This is a **separate thread pool** from the Java SDK's activity executor thread pool. + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ JAVA SDK THREAD POOL │ +│ (ActivityWorker's executor) │ +│ │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │Thread-1 │ │Thread-2 │ │Thread-3 │ │Thread-4 │ ... │ +│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │ +│ │ │ │ │ │ +│ │ poll() │ poll() │ poll() │ poll() │ +│ │ │ │ │ │ +│ ▼ │ │ │ │ +│ invoke() ─────────┼────────────┼────────────┼──────────────────────┐ │ +│ │ │ │ │ │ │ +│ │ launch() │ │ │ │ │ +│ │ │ │ │ │ │ +│ ▼ │ │ │ │ │ +│ return (FREE!) │ │ │ │ │ +│ │ │ │ │ │ │ +│ ▼ │ │ │ │ │ +│ poll() again │ │ │ │ │ +│ │ │ │ │ │ +└─────────────────────┼────────────┼────────────┼──────────────────────┼──────┘ + │ │ │ │ + │ │ │ │ +┌─────────────────────┼────────────┼────────────┼──────────────────────┼──────┐ +│ │ │ │ │ │ +│ COROUTINE DISPATCHER THREAD POOL │ │ +│ (Kotlin coroutine threads) │ │ +│ │ │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ +│ │ Coro-1 │ │ Coro-2 │ │ Coro-3 │ │ Coro-4 │ ... │ │ +│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │ │ +│ │ │ │ │ │ │ +│ ◄────────────┼────────────┼────────────┼──────────────────────┘ │ +│ │ │ │ │ │ +│ ▼ │ │ │ │ +│ Execute suspend │ │ │ │ +│ function │ │ │ │ +│ │ │ │ │ │ +│ (suspend on I/O) │ │ │ │ +│ │ │ │ │ │ +│ ▼ │ │ │ │ +│ Thread released │ │ │ │ +│ . │ │ │ │ +│ . │ │ │ │ +│ . (waiting) │ │ │ │ +│ . │ │ │ │ +│ ▼ │ │ │ │ +│ Resume on Coro-3 ─┼────────────► │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ Continue execution │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ complete(result) │ │ +│ │ │ │ │ +└─────────────────────┴────────────┴────────────┴──────────────────────────────┘ +``` + +**Key point**: The coroutine can start on one thread (Coro-1), suspend, and resume on a completely +different thread (Coro-3). This is normal Kotlin coroutine behavior. + +### Built-in Dispatchers + +Kotlin provides several built-in dispatchers: + +| Dispatcher | Thread Pool | Size | Best For | +|------------|-------------|------|----------| +| `Dispatchers.Default` | Shared, fixed | CPU cores (min 2) | CPU-bound + async I/O | +| `Dispatchers.IO` | Shared, elastic | Up to 64+ threads | Blocking I/O calls | +| `Dispatchers.Unconfined` | Caller's thread | N/A | Testing only | + +```kotlin +// Dispatchers.Default - recommended for most cases +// Backed by a shared ForkJoinPool +val dispatcher = Dispatchers.Default + +// Dispatchers.IO - for blocking calls within suspend functions +// Shares threads with Default but can grow larger +val dispatcher = Dispatchers.IO + +// Limited parallelism - caps concurrent coroutines +val dispatcher = Dispatchers.Default.limitedParallelism(100) +``` + +### Custom Dispatcher from Java Executor + +You can create a dispatcher from any Java `Executor` or `ExecutorService`: + +```kotlin +import kotlinx.coroutines.asCoroutineDispatcher +import java.util.concurrent.Executors + +// From a fixed thread pool +val executor = Executors.newFixedThreadPool(50) +val dispatcher = executor.asCoroutineDispatcher() + +// From a cached thread pool (elastic) +val executor = Executors.newCachedThreadPool() +val dispatcher = executor.asCoroutineDispatcher() + +// From a custom ThreadPoolExecutor +val executor = ThreadPoolExecutor( + 10, // core pool size + 100, // max pool size + 60L, // keep-alive time + TimeUnit.SECONDS, + LinkedBlockingQueue() +) +val dispatcher = executor.asCoroutineDispatcher() + +// IMPORTANT: Remember to close the dispatcher when done +dispatcher.close() // Also shuts down the executor +``` + +### Configuration API + +```kotlin +// Option 1: Use built-in dispatcher (simplest) +val worker = factory.newWorker("my-task-queue") { + suspendActivityDispatcher = Dispatchers.Default +} + +// Option 2: Limited parallelism for backpressure +val worker = factory.newWorker("my-task-queue") { + // Max 100 concurrent suspend activities + suspendActivityDispatcher = Dispatchers.Default.limitedParallelism(100) +} + +// Option 3: Custom executor for full control +val activityExecutor = Executors.newFixedThreadPool(200) +val worker = factory.newWorker("my-task-queue") { + suspendActivityDispatcher = activityExecutor.asCoroutineDispatcher() +} + +// Option 4: Match Java SDK's activity thread pool size +val worker = factory.newWorker("my-task-queue") { + val maxConcurrent = workerOptions.maxConcurrentActivityExecutionSize + suspendActivityDispatcher = Dispatchers.Default.limitedParallelism(maxConcurrent) +} +``` + +### Dispatcher Lifecycle Management + +```kotlin +class KWorkerFactory(client: KWorkflowClient) : Closeable { + + private var customDispatcher: CloseableCoroutineDispatcher? = null + + fun newWorker(taskQueue: String, configure: WorkerConfig.() -> Unit): KWorker { + val config = WorkerConfig().apply(configure) + + // Track custom dispatchers for cleanup + if (config.suspendActivityDispatcher is CloseableCoroutineDispatcher) { + customDispatcher = config.suspendActivityDispatcher + } + + // ... + } + + override fun close() { + // Clean up custom dispatchers + customDispatcher?.close() + // ... + } +} +``` + +### Thread Flow Example + +``` +Time ──────────────────────────────────────────────────────────────────────────► + +Java SDK Thread Pool (4 threads): +┌──────────────────────────────────────────────────────────────────────────────┐ +│ T1: [poll]──[invoke]──[return]──[poll]──[invoke]──[return]──[poll]──... │ +│ T2: [poll]──────────────────────[invoke]──[return]──[poll]──... │ +│ T3: [poll]──[invoke]──[return]──[poll]──... │ +│ T4: [poll]──────────────────────────────[invoke]──[return]──[poll]──... │ +└──────────────────────────────────────────────────────────────────────────────┘ + │ │ │ │ + │ launch │ launch │ launch │ launch + ▼ ▼ ▼ ▼ + +Coroutine Dispatcher (separate pool): +┌──────────────────────────────────────────────────────────────────────────────┐ +│ C1: [run]─[suspend]─────────────[resume]─[complete] │ +│ C2: [run]─[suspend]───────────────────────[resume]─[suspend]─[resume] │ +│ C3: [run]─[complete] │ +│ C4: [run]─[suspend]─────────[resume]─[complete] │ +└──────────────────────────────────────────────────────────────────────────────┘ + +Legend: +- [poll] = Waiting for activity task from server +- [invoke] = Calling SuspendActivityInvoker.invoke() +- [return] = Method returns, thread freed +- [launch] = Coroutine scheduled on dispatcher +- [run] = Coroutine executing code +- [suspend] = Coroutine suspended (waiting for I/O) +- [resume] = Coroutine resumed after I/O +- [complete]= Activity completed via ManualActivityCompletionClient +``` + +### Recommendations + +1. **Start with `Dispatchers.Default`** - It's well-tuned for most workloads + +2. **Use `limitedParallelism()` for backpressure** - Prevents unbounded coroutine growth: + ```kotlin + Dispatchers.Default.limitedParallelism(maxConcurrentActivities) + ``` + +3. **Use `Dispatchers.IO` for blocking calls** - If your suspend activity must call blocking APIs: + ```kotlin + suspend fun myActivity() { + withContext(Dispatchers.IO) { + // Blocking call here won't block Default dispatcher + legacyBlockingApi.call() + } + } + ``` + +4. **Custom executor for isolation** - If you need suspend activities isolated from other coroutines: + ```kotlin + val dedicatedExecutor = Executors.newFixedThreadPool(50) + val dedicatedDispatcher = dedicatedExecutor.asCoroutineDispatcher() + ``` + +## Heartbeat Configuration + +```kotlin +val worker = factory.newWorker("my-task-queue") { + // Dispatcher for suspend activities + suspendActivityDispatcher = Dispatchers.Default.limitedParallelism(100) + + // Auto-heartbeat interval for suspend activities (optional) + // If set, a background coroutine sends heartbeats automatically + suspendActivityHeartbeatInterval = 30.seconds +} + +// Per-activity configuration via annotation (future enhancement) +@SuspendActivityOptions( + heartbeatInterval = "10s" +) +suspend fun myActivity(): Result +``` + +## Implementation Plan + +### Phase 1: Core Infrastructure +1. Create `SuspendActivityInvoker` class +2. Create `KActivityExecutionContext` with coroutine support +3. Create `KActivityExecutionContextElement` for coroutine context +4. Implement basic suspend activity execution + +### Phase 2: Cancellation Handling +1. Implement auto-heartbeat background job +2. Map `CanceledFailure` to coroutine cancellation +3. Ensure proper cleanup on cancellation + +### Phase 3: Activity Registration +1. Detect suspend functions at registration time +2. Create `SuspendActivityInvoker` for suspend methods +3. Register with Java SDK's activity framework + +### Phase 4: API and Configuration +1. Update `KActivity` API for coroutine support +2. Add dispatcher configuration options +3. Add heartbeat interval configuration + +### Phase 5: Testing +1. Unit tests for suspend activity execution +2. Integration tests with real async I/O (Ktor) +3. Cancellation handling tests +4. Concurrent execution tests + +## Comparison: Old Design vs New Design + +| Aspect | Old Design (runBlocking) | New Design (useLocalManualCompletion) | +|--------|--------------------------|---------------------------------------| +| Thread during suspend | **Blocked** | **Free** | +| I/O efficiency | Low | High | +| Concurrent activities | Limited by threads | Limited by coroutines (much higher) | +| Java SDK changes | None needed | None needed | +| Complexity | Lower | Moderate | +| Cancellation | Immediate | Within heartbeat interval | + +## Considerations + +### Dispatcher Selection + +**Dispatchers.Default** (recommended): +- Shared thread pool sized to CPU cores +- Good for mixed CPU/IO workloads +- Can be limited with `limitedParallelism(n)` + +**Dispatchers.IO**: +- Elastic thread pool for blocking I/O +- Overhead of thread creation +- Use if calling blocking APIs from suspend context + +**Custom dispatcher**: +- Full control over thread pool +- Can match `maxConcurrentActivityExecutionSize` for parity + +### Heartbeat Timing + +The cancellation detection latency equals the heartbeat interval. For activities that need fast cancellation response: +- Use shorter heartbeat intervals (e.g., 5 seconds) +- Or call `KActivity.heartbeat()` explicitly at key points + +### Error Handling + +| Exception Type | Handling | +|----------------|----------| +| Regular exception | `client.fail(e)` - Activity fails, may retry | +| `CancellationException` | `client.reportCancellation(null)` - Clean cancellation | +| Error during completion | Logged, slot released, activity marked failed | + +### Structured Concurrency + +Activities can use `coroutineScope` for structured concurrency: +```kotlin +suspend fun processInParallel(items: List): List { + return coroutineScope { + items.map { async { process(it) } }.awaitAll() + } +} +``` + +All child coroutines are cancelled if the activity is cancelled. + +## Open Questions + +1. **Default heartbeat interval**: Should we have a default auto-heartbeat interval for suspend activities, or require explicit configuration? + +2. **Exception translation**: Should we map specific Kotlin exceptions (e.g., `TimeoutCancellationException`) to specific Temporal failures? + +3. **Context propagation**: How to propagate MDC/tracing context through coroutines? + +4. **Metrics**: Should we expose coroutine-specific metrics (active coroutines, suspension points)? + +## Conclusion + +This design leverages the existing `useLocalManualCompletion()` pattern in the Java SDK to achieve truly non-blocking suspend activities. Key benefits: + +- **No thread blocking**: Executor thread is freed immediately after launching coroutine +- **High concurrency**: Can run many more concurrent I/O-bound activities than threads +- **No Java SDK changes**: Works with existing infrastructure +- **Kotlin-idiomatic**: Uses standard coroutine patterns and context +- **Backward compatible**: Regular (non-suspend) activities continue to work unchanged + +The approach provides efficient async I/O support while maintaining Temporal's reliability guarantees for activity execution, retries, and timeouts. diff --git a/kotlin/implementation/test-framework-design.md b/kotlin/implementation/test-framework-design.md new file mode 100644 index 0000000..843c2c5 --- /dev/null +++ b/kotlin/implementation/test-framework-design.md @@ -0,0 +1,970 @@ +# Kotlin SDK Test Framework Design + +## Overview + +This document describes the user experience for testing Temporal workflows and activities in the Kotlin SDK. The test framework provides Kotlin-idiomatic APIs while maintaining consistency with the Java SDK where appropriate. + +## Design Principles + +1. **Kotlin-Idiomatic**: DSL builders, suspend functions, extension functions, null safety +2. **Coroutine-Native**: Full support for suspend functions and coroutine-based testing +3. **Minimal Boilerplate**: Sensible defaults, concise APIs +4. **Java Interoperability**: Can test Kotlin workflows calling Java activities and vice versa +5. **Familiar to Java Users**: Similar structure and concepts as Java SDK test framework + +--- + +## Package Structure + +Following the Java SDK pattern: +``` +io.temporal.kotlin.testing +├── KTestWorkflowEnvironment +├── KTestWorkflowExtension +├── KTestActivityEnvironment +├── KTestActivityExtension +└── KTestEnvironmentOptions +``` + +--- + +## 1. KTestWorkflowEnvironment + +The core interface for workflow testing, providing an in-memory Temporal service with automatic time skipping. + +### Creating a Test Environment + +```kotlin +// Simple creation with defaults +val testEnv = KTestWorkflowEnvironment.newInstance() + +// With configuration DSL +val testEnv = KTestWorkflowEnvironment.newInstance { + namespace = "test-namespace" + initialTime = Instant.parse("2024-01-01T00:00:00Z") + useTimeskipping = true + + workerFactoryOptions { + // WorkerFactoryOptions configuration + } + + workflowClientOptions { + // WorkflowClientOptions configuration + } + + searchAttributes { + register("CustomKeyword", IndexedValueType.INDEXED_VALUE_TYPE_KEYWORD) + register("CustomInt", IndexedValueType.INDEXED_VALUE_TYPE_INT) + } +} +``` + +### Worker Management + +```kotlin +// Create a KWorker for a task queue (returns KWorker for pure Kotlin) +val worker: KWorker = testEnv.newWorker("my-task-queue") + +// With worker options DSL +val worker: KWorker = testEnv.newWorker("my-task-queue") { + maxConcurrentActivityExecutionSize = 100 + maxConcurrentWorkflowTaskExecutionSize = 50 +} + +// Register workflow and activity implementations +worker.registerWorkflowImplementationTypes() +worker.registerActivitiesImplementations(MyActivitiesImpl()) + +// Register Kotlin suspend activities +worker.registerSuspendActivities(MySuspendActivitiesImpl()) +``` + +### KWorker API + +`KWorker` is the Kotlin-idiomatic worker for pure Kotlin implementations: + +```kotlin +/** + * Kotlin worker that provides idiomatic APIs for registering + * Kotlin workflows and suspend activities. + */ +class KWorker { + /** The underlying Java Worker for interop scenarios */ + val worker: Worker + + /** Register Kotlin workflow implementation types */ + inline fun registerWorkflowImplementationTypes() + fun registerWorkflowImplementationTypes(vararg workflowClasses: KClass<*>) + fun registerWorkflowImplementationTypes( + options: WorkflowImplementationOptions, + vararg workflowClasses: KClass<*> + ) + + /** Register activity implementations (both regular and suspend) */ + fun registerActivitiesImplementations(vararg activities: Any) + + /** Register suspend activity implementations */ + fun registerSuspendActivities(vararg activities: Any) + + /** Register Nexus service implementations */ + fun registerNexusServiceImplementations(vararg services: Any) +} +``` + +**When to use `KWorker` vs `Worker`:** +- Use `KWorker` for pure Kotlin implementations (workflows and activities) +- Use `Worker` (via `worker` property) when mixing Java and Kotlin workflows/activities + +### Client Access + +```kotlin +// Get the workflow client (KWorkflowClient) +val client: KWorkflowClient = testEnv.workflowClient + +// Start and execute workflows +val result = client.executeWorkflow( + MyWorkflow::execute, + KWorkflowOptions(taskQueue = "my-task-queue"), + "input" +) +``` + +### Time Manipulation + +```kotlin +// Get current test time +val currentTime: Instant = testEnv.currentTime +val currentTimeMillis: Long = testEnv.currentTimeMillis + +// Sleep with time skipping (suspend function) +testEnv.sleep(5.minutes) +testEnv.sleep(Duration.ofMinutes(5)) // Java Duration also supported + +// Register delayed callbacks +testEnv.registerDelayedCallback(10.seconds) { + // This executes when test time reaches 10 seconds + println("10 seconds have passed in test time") +} +``` + +### Lifecycle Management + +```kotlin +// Start all registered workers +testEnv.start() + +// Shutdown +testEnv.shutdown() // Graceful shutdown +testEnv.shutdownNow() // Immediate shutdown + +// Await termination (suspend function) +testEnv.awaitTermination(30.seconds) + +// Check status +val isStarted: Boolean = testEnv.isStarted +val isShutdown: Boolean = testEnv.isShutdown +val isTerminated: Boolean = testEnv.isTerminated + +// Auto-close with use {} +KTestWorkflowEnvironment.newInstance().use { testEnv -> + // Test code here + // Automatically closed when block exits +} +``` + +### Diagnostics + +```kotlin +// Get diagnostic information (workflow histories) +val diagnostics: String = testEnv.getDiagnostics() +``` + +### Service Access (Advanced) + +```kotlin +// Direct access to gRPC stubs +val workflowStubs: WorkflowServiceStubs = testEnv.workflowServiceStubs +val operatorStubs: OperatorServiceStubs = testEnv.operatorServiceStubs +val namespace: String = testEnv.namespace +``` + +--- + +## 2. KTestWorkflowExtension (JUnit 5) + +A JUnit 5 extension that simplifies workflow testing with automatic lifecycle management and parameter injection. + +### Basic Usage + +```kotlin +class MyWorkflowTest { + companion object { + @JvmField + @RegisterExtension + val testWorkflow = kTestWorkflowExtension { + registerWorkflowImplementationTypes() + setActivityImplementations(MyActivitiesImpl()) + } + } + + @Test + fun `test workflow execution`(workflow: MyWorkflow) { + val result = workflow.execute("input") + assertEquals("expected", result) + } + + @Test + fun `test with environment access`( + workflow: MyWorkflow, + testEnv: KTestWorkflowEnvironment, + client: KWorkflowClient + ) { + // Access to all test components via parameter injection + testEnv.sleep(5.minutes) + val result = workflow.execute("input") + assertEquals("expected", result) + } +} +``` + +### Configuration DSL + +```kotlin +val testWorkflow = kTestWorkflowExtension { + // Workflow Registration + registerWorkflowImplementationTypes() + registerWorkflowImplementationTypes() + + // With workflow implementation options + registerWorkflowImplementationTypes { + failWorkflowExceptionTypes = listOf(MyBusinessException::class.java) + } + + // Activity Registration + setActivityImplementations(MyActivitiesImpl()) + + // Suspend Activity Registration + setSuspendActivityImplementations(MySuspendActivitiesImpl()) + + // Nexus Service Registration + setNexusServiceImplementations(MyNexusServiceImpl()) + + // Worker Configuration + workerOptions { + maxConcurrentActivityExecutionSize = 100 + } + + workerFactoryOptions { + // WorkerFactoryOptions configuration + } + + // Client Configuration + workflowClientOptions { + // WorkflowClientOptions configuration + } + + namespace = "TestNamespace" // Default: "UnitTest" + + // Service Selection + useInternalService() // Default - in-memory service + // OR + useExternalService() // Uses ClientConfigProfile.load() - reads from env vars (TEMPORAL_ADDRESS, etc.) + // OR + useExternalService("custom-host:7233") // Explicit address override + + // Time Configuration + initialTime = Instant.parse("2024-01-01T00:00:00Z") + useTimeskipping = true // Default: true + + // Search Attributes + searchAttributes { + register("CustomAttr", IndexedValueType.INDEXED_VALUE_TYPE_KEYWORD) + } + + // Defer worker startup (for mock registration) + doNotStart = true +} +``` + +### Parameter Injection + +The extension supports injecting the following types into test methods: + +| Type | Description | +|------|-------------| +| `KTestWorkflowEnvironment` | The test environment | +| `KWorkflowClient` | The workflow client | +| `KWorkflowOptions` | Default workflow options | +| `KWorker` | The Kotlin worker instance | +| `Worker` | The Java worker instance (for mixed Java/Kotlin) | +| `` (workflow interface) | A workflow stub ready to execute | + +**Note:** Use `KWorker` for pure Kotlin implementations. Use `Worker` only when mixing Java and Kotlin workflows/activities in the same test. + +```kotlin +@Test +fun `test with all injected parameters`( + testEnv: KTestWorkflowEnvironment, + client: KWorkflowClient, + worker: KWorker, + options: KWorkflowOptions, + workflow: MyWorkflow // Automatically creates stub +) { + // All parameters automatically injected +} +``` + +### Initial Time Annotation + +Override the initial time for specific tests: + +```kotlin +@Test +@WorkflowInitialTime("2024-06-15T12:00:00Z") +fun `test with specific initial time`(workflow: MyWorkflow) { + // Test runs with specified initial time +} +``` + +### Testing with Mocks + +```kotlin +class MockedActivityTest { + companion object { + @JvmField + @RegisterExtension + val testWorkflow = kTestWorkflowExtension { + registerWorkflowImplementationTypes() + doNotStart = true // Important: defer startup for mock registration + } + } + + @Test + fun `test with mocked activities`( + testEnv: KTestWorkflowEnvironment, + worker: KWorker, + workflow: MyWorkflow + ) { + // Create mock + val mockActivity = mock { + on { doSomething(any()) } doReturn "mocked result" + } + + // Register mock and start + worker.registerActivitiesImplementations(mockActivity) + testEnv.start() + + // Execute workflow + val result = workflow.execute("input") + assertEquals("mocked result", result) + + // Verify + verify(mockActivity).doSomething(any()) + } +} +``` + +--- + +## 3. KTestActivityEnvironment + +For unit testing activity implementations in isolation. + +### Creating an Activity Test Environment + +```kotlin +// Simple creation +val activityEnv = KTestActivityEnvironment.newInstance() + +// With configuration +val activityEnv = KTestActivityEnvironment.newInstance { + workflowClientOptions { + // Configuration + } +} +``` + +### Registering and Testing Activities + +```kotlin +// Register activity implementations +activityEnv.registerActivitiesImplementations(MyActivitiesImpl()) + +// Register suspend activities +activityEnv.registerSuspendActivities(MySuspendActivitiesImpl()) + +// Execute activity using method reference (same pattern as KWorkflow.executeActivity) +val result = activityEnv.executeActivity( + MyActivity::doSomething, + KActivityOptions(startToCloseTimeout = 30.seconds), + "input" +) +assertEquals("expected", result) + +// With retry options +val result = activityEnv.executeActivity( + MyActivity::processOrder, + KActivityOptions( + startToCloseTimeout = 30.seconds, + retryOptions = KRetryOptions(maximumAttempts = 3) + ), + orderData +) +``` + +### Testing Local Activities + +```kotlin +val result = activityEnv.executeLocalActivity( + MyActivity::validate, + KLocalActivityOptions(startToCloseTimeout = 10.seconds), + input +) +assertEquals("valid", result) +``` + +### Heartbeat Testing + +```kotlin +// Set heartbeat details for the next activity call (simulates retry with heartbeat) +activityEnv.setHeartbeatDetails("checkpoint-data") + +// Listen for heartbeats during activity execution +activityEnv.setActivityHeartbeatListener { details -> + println("Activity heartbeat: $details") +} + +// With complex types +activityEnv.setActivityHeartbeatListener { progress -> + println("Progress: ${progress.percentage}%") +} +``` + +### Cancellation Testing + +```kotlin +@Test +fun `test activity cancellation handling`() { + val activityEnv = KTestActivityEnvironment.newInstance() + activityEnv.registerActivitiesImplementations(MyActivitiesImpl()) + + // Request cancellation during activity execution + activityEnv.requestCancelActivity() + + assertThrows { + activityEnv.executeActivity( + MyActivity::longRunningTask, + KActivityOptions(startToCloseTimeout = 1.minutes) + ) + } +} +``` + +### Lifecycle + +```kotlin +// Auto-close with use {} +KTestActivityEnvironment.newInstance().use { activityEnv -> + // Test code +} + +// Manual close +activityEnv.close() +``` + +--- + +## 4. KTestActivityExtension (JUnit 5) + +A JUnit 5 extension for simplified activity testing. + +### Basic Usage + +```kotlin +class MyActivityTest { + companion object { + @JvmField + @RegisterExtension + val testActivity = kTestActivityExtension { + setActivityImplementations(MyActivitiesImpl()) + } + } + + @Test + fun `test activity`(activityEnv: KTestActivityEnvironment) { + val result = activityEnv.executeActivity( + MyActivity::doSomething, + KActivityOptions(startToCloseTimeout = 30.seconds), + "input" + ) + assertEquals("expected", result) + } + + @Test + fun `test with heartbeat`(activityEnv: KTestActivityEnvironment) { + activityEnv.setHeartbeatDetails("checkpoint") + val result = activityEnv.executeActivity( + MyActivity::resumableTask, + KActivityOptions( + startToCloseTimeout = 1.minutes, + heartbeatTimeout = 10.seconds + ) + ) + assertEquals("completed", result) + } +} +``` + +### Configuration + +```kotlin +val testActivity = kTestActivityExtension { + setActivityImplementations(MyActivitiesImpl()) + setSuspendActivityImplementations(MySuspendActivitiesImpl()) + + testEnvironmentOptions { + workflowClientOptions { + // Configuration + } + } +} +``` + +--- + +## 5. Testing Suspend Activities + +The Kotlin SDK provides first-class support for testing suspend activities. + +### Unit Testing Suspend Activities + +```kotlin +class SuspendActivityTest { + companion object { + @JvmField + @RegisterExtension + val testActivity = kTestActivityExtension { + setSuspendActivityImplementations(MySuspendActivitiesImpl()) + } + } + + @Test + fun `test suspend activity`(activityEnv: KTestActivityEnvironment) = runTest { + val result = activityEnv.executeActivity( + MySuspendActivities::fetchDataAsync, + KActivityOptions(startToCloseTimeout = 30.seconds), + "key" + ) + assertEquals("value", result) + } +} +``` + +### Integration Testing with Workflows + +```kotlin +class WorkflowWithSuspendActivitiesTest { + companion object { + @JvmField + @RegisterExtension + val testWorkflow = kTestWorkflowExtension { + registerWorkflowImplementationTypes() + setSuspendActivityImplementations(MySuspendActivitiesImpl()) + } + } + + @Test + fun `workflow calls suspend activities`(workflow: MyWorkflow) { + val result = workflow.processData("input") + assertEquals("processed", result) + } +} +``` + +--- + +## 6. Common Testing Patterns + +### Pattern 1: Basic Workflow Test + +```kotlin +class BasicWorkflowTest { + companion object { + @JvmField + @RegisterExtension + val testWorkflow = kTestWorkflowExtension { + registerWorkflowImplementationTypes() + setActivityImplementations(GreetingActivitiesImpl()) + } + } + + @Test + fun `greets user correctly`(workflow: GreetingWorkflow) { + val result = workflow.greet("World") + assertEquals("Hello, World!", result) + } +} +``` + +### Pattern 2: Testing Signals and Queries + +```kotlin +@Test +fun `handles signals correctly`( + workflow: OrderWorkflow, + testEnv: KTestWorkflowEnvironment, + client: KWorkflowClient +) = runBlocking { + // Start workflow asynchronously + val handle = client.startWorkflow( + OrderWorkflow::processOrder, + KWorkflowOptions( + taskQueue = "orders", + workflowId = "order-workflow-1" + ), + "order-123" + ) + + // Let workflow start + testEnv.sleep(1.seconds) + + // Query current state + val status = handle.query(OrderWorkflow::getStatus) + assertEquals("PENDING", status) + + // Send signal + handle.signal(OrderWorkflow::approve, "manager-1") + + // Wait for completion + val result = handle.result() + assertEquals("COMPLETED", result) +} +``` + +### Pattern 3: Testing Updates + +```kotlin +@Test +fun `handles updates correctly`( + workflow: CounterWorkflow, + testEnv: KTestWorkflowEnvironment, + client: KWorkflowClient +) = runBlocking { + val handle = client.startWorkflow( + CounterWorkflow::run, + KWorkflowOptions( + taskQueue = "counters", + workflowId = "counter-1" + ) + ) + + testEnv.sleep(1.seconds) + + // Execute update and get result + val newValue = handle.executeUpdate(CounterWorkflow::increment, 5) + assertEquals(5, newValue) + + val finalValue = handle.executeUpdate(CounterWorkflow::increment, 3) + assertEquals(8, finalValue) +} +``` + +### Pattern 4: Testing Time-Dependent Workflows + +```kotlin +@Test +@WorkflowInitialTime("2024-01-01T00:00:00Z") +fun `processes scheduled tasks`( + workflow: ScheduledWorkflow, + testEnv: KTestWorkflowEnvironment +) { + // Start workflow that waits for specific time + val future = async { workflow.waitUntilTime() } + + // Advance time by 1 hour (time skipping makes this instant) + testEnv.sleep(1.hours) + + val result = future.await() + assertEquals("executed at scheduled time", result) +} +``` + +### Pattern 5: Testing Retries and Failures + +```kotlin +@Test +fun `retries failed activities`( + testEnv: KTestWorkflowEnvironment, + worker: KWorker, + workflow: RetryWorkflow +) { + var attempts = 0 + val mockActivity = mock { + on { unreliableCall() } doAnswer { + attempts++ + if (attempts < 3) { + throw RuntimeException("Temporary failure") + } + "success" + } + } + + worker.registerActivitiesImplementations(mockActivity) + testEnv.start() + + val result = workflow.executeWithRetry() + + assertEquals("success", result) + assertEquals(3, attempts) +} +``` + +### Pattern 6: Testing Child Workflows + +```kotlin +@Test +fun `executes child workflows`(workflow: ParentWorkflow) { + val result = workflow.processWithChildren(listOf("a", "b", "c")) + assertEquals(listOf("processed-a", "processed-b", "processed-c"), result) +} +``` + +### Pattern 7: Testing Continue-As-New + +```kotlin +@Test +fun `continues as new after threshold`( + workflow: BatchWorkflow, + testEnv: KTestWorkflowEnvironment +) { + // Workflow that continues-as-new every 100 items + val result = workflow.processBatch((1..250).toList()) + + assertEquals(250, result.processedCount) + assertEquals(3, result.continuations) // 100 + 100 + 50 +} +``` + +### Pattern 8: Async Activity Completion + +```kotlin +@Test +fun `handles async activity completion`(activityEnv: KTestActivityEnvironment) { + activityEnv.registerActivitiesImplementations(AsyncActivityImpl()) + + // Activity signals async completion + assertThrows { + activityEnv.executeActivity( + AsyncActivity::startAsyncOperation, + KActivityOptions(startToCloseTimeout = 1.minutes) + ) + } +} +``` + +### Pattern 9: Testing with External Service + +```kotlin +class ExternalServiceTest { + companion object { + @JvmField + @RegisterExtension + val testWorkflow = kTestWorkflowExtension { + registerWorkflowImplementationTypes() + setActivityImplementations(MyActivitiesImpl()) + // Uses ClientConfigProfile.load() which reads from: + // - TEMPORAL_ADDRESS (service address) + // - TEMPORAL_NAMESPACE (namespace) + // - TEMPORAL_API_KEY (optional API key) + // - TEMPORAL_TLS_* (TLS configuration) + useExternalService() + } + } + + @Test + fun `works with real temporal service`(workflow: MyWorkflow) { + val result = workflow.execute("input") + assertEquals("expected", result) + } +} + +// Alternative: explicit address override +class ExplicitAddressTest { + companion object { + @JvmField + @RegisterExtension + val testWorkflow = kTestWorkflowExtension { + registerWorkflowImplementationTypes() + setActivityImplementations(MyActivitiesImpl()) + useExternalService("localhost:7233") // Explicit address + namespace = "test-namespace" + } + } +} +``` + +### Pattern 10: Diagnostics on Failure + +```kotlin +class DiagnosticTest { + companion object { + @JvmField + @RegisterExtension + val testWorkflow = kTestWorkflowExtension { + registerWorkflowImplementationTypes() + setActivityImplementations(MyActivitiesImpl()) + } + } + + @Test + fun `test with diagnostics on failure`( + workflow: MyWorkflow, + testEnv: KTestWorkflowEnvironment + ) { + try { + workflow.execute("input") + } catch (e: Exception) { + // Print workflow histories for debugging + println(testEnv.getDiagnostics()) + throw e + } + } +} +``` + +--- + +## 7. Builder Functions + +For cases where the DSL is not preferred, traditional builders are available: + +```kotlin +// Test environment +val testEnv = KTestWorkflowEnvironment.newInstance( + KTestEnvironmentOptions.newBuilder() + .setNamespace("test") + .setInitialTime(Instant.now()) + .setUseTimeskipping(true) + .build() +) + +// Test extension +val testWorkflow = KTestWorkflowExtension.newBuilder() + .registerWorkflowImplementationTypes(MyWorkflowImpl::class.java) + .setActivityImplementations(MyActivitiesImpl()) + .build() +``` + +--- + +## 8. Integration with kotlinx-coroutines-test + +The test framework integrates with `kotlinx-coroutines-test` for coroutine testing: + +```kotlin +@Test +fun `test with coroutine test utilities`( + workflow: MyWorkflow, + testEnv: KTestWorkflowEnvironment +) = runTest { + // Use kotlinx-coroutines-test utilities + val result = workflow.execute("input") + assertEquals("expected", result) +} +``` + +--- + +## 9. Summary: Java vs Kotlin Comparison + +| Java SDK | Kotlin SDK | Notes | +|----------|------------|-------| +| `TestWorkflowEnvironment.newInstance()` | `KTestWorkflowEnvironment.newInstance()` | Same pattern | +| `TestWorkflowEnvironment.newInstance(options)` | `KTestWorkflowEnvironment.newInstance { }` | DSL builder | +| `TestWorkflowExtension.newBuilder()...build()` | `kTestWorkflowExtension { }` | DSL builder | +| `testEnv.newWorker()` returns `Worker` | `testEnv.newWorker()` returns `KWorker` | Kotlin wrapper | +| `Worker` | `KWorker` (with `worker` property for interop) | Kotlin-idiomatic registration | +| `testEnv.sleep(Duration)` | `testEnv.sleep(Duration)` | Suspend function, supports kotlin.time | +| `testEnv.currentTimeMillis()` | `testEnv.currentTimeMillis` / `testEnv.currentTime` | Property access + Instant | +| `TestActivityEnvironment` | `KTestActivityEnvironment` | Same pattern | +| `activityEnv.newActivityStub(T.class, options)` | `activityEnv.executeActivity(T::method, options, args)` | Handle approach (consistent with SDK) | +| `testEnv.getWorkflowClient()` | `testEnv.workflowClient` | Property access | +| N/A | `setSuspendActivityImplementations()` | Kotlin-specific | +| Builder pattern | DSL + Builder pattern | Both available | + +--- + +## 10. External Service Configuration + +When using `useExternalService()` without an explicit address, the test framework uses `ClientConfigProfile.load()` to load configuration from environment variables and config files. + +### Environment Variables + +| Variable | Description | +|----------|-------------| +| `TEMPORAL_ADDRESS` | Service address (e.g., `localhost:7233` or `myns.tmprl.cloud:7233`) | +| `TEMPORAL_NAMESPACE` | Namespace to use | +| `TEMPORAL_API_KEY` | API key for authentication (Temporal Cloud) | +| `TEMPORAL_TLS` | Enable TLS (`true`/`false`) | +| `TEMPORAL_TLS_CLIENT_CERT_PATH` | Path to client certificate file | +| `TEMPORAL_TLS_CLIENT_KEY_PATH` | Path to client key file | +| `TEMPORAL_TLS_SERVER_CA_CERT_PATH` | Path to CA certificate file | +| `TEMPORAL_TLS_SERVER_NAME` | Server name for TLS verification | +| `TEMPORAL_TLS_DISABLE_HOST_VERIFICATION` | Disable host verification (`true`/`false`) | +| `TEMPORAL_PROFILE` | Config file profile to use (default: `default`) | +| `TEMPORAL_GRPC_META_*` | Custom gRPC metadata (e.g., `TEMPORAL_GRPC_META_MY_HEADER=value`) | + +### Example: Running Tests Against Temporal Cloud + +```bash +export TEMPORAL_ADDRESS="myns.tmprl.cloud:7233" +export TEMPORAL_NAMESPACE="myns" +export TEMPORAL_API_KEY="my-api-key" + +./gradlew test +``` + +### Example: Running Tests Against Local Server with mTLS + +```bash +export TEMPORAL_ADDRESS="localhost:7233" +export TEMPORAL_NAMESPACE="default" +export TEMPORAL_TLS=true +export TEMPORAL_TLS_CLIENT_CERT_PATH="/path/to/client.pem" +export TEMPORAL_TLS_CLIENT_KEY_PATH="/path/to/client.key" +export TEMPORAL_TLS_SERVER_CA_CERT_PATH="/path/to/ca.pem" + +./gradlew test +``` + +--- + +## 11. Dependencies + +```kotlin +// build.gradle.kts +dependencies { + testImplementation("io.temporal:temporal-kotlin-testing:VERSION") + + // JUnit 5 + testImplementation("org.junit.jupiter:junit-jupiter:5.10.0") + + // Coroutine testing (optional) + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") + + // Mocking (optional) + testImplementation("org.mockito.kotlin:mockito-kotlin:5.1.0") +} +``` + +--- + +## 12. Implementation Notes + +1. **KTestWorkflowEnvironment** wraps `TestWorkflowEnvironment` and returns `KWorkflowClient` +2. **KWorker** wraps `Worker` with Kotlin-idiomatic registration APIs; exposes underlying `Worker` for interop +3. **Time functions** use `kotlin.time.Duration` with `java.time.Duration` overloads +4. **Suspend functions** for `sleep()` and `awaitTermination()` +5. **Extension functions** provide Kotlin-idiomatic APIs on Java types +6. **DSL builders** internally use Java builders for configuration +7. **Parameter injection** in extensions works via JUnit 5's `ParameterResolver` diff --git a/kotlin/implementation/test-framework-implementation-design.md b/kotlin/implementation/test-framework-implementation-design.md new file mode 100644 index 0000000..ebed3c6 --- /dev/null +++ b/kotlin/implementation/test-framework-implementation-design.md @@ -0,0 +1,1756 @@ +# Kotlin SDK Test Framework Implementation Design + +## Overview + +This document describes the implementation design for the Kotlin SDK test framework based on the [Test Framework Design API](./test-framework-design.md). The implementation wraps the Java SDK's test framework while providing Kotlin-idiomatic APIs. + +## Package Structure + +``` +io.temporal.kotlin.testing/ +├── KTestWorkflowEnvironment.kt # Main test environment +├── KTestWorkflowExtension.kt # JUnit 5 workflow extension +├── KTestActivityEnvironment.kt # Activity test environment +├── KTestActivityExtension.kt # JUnit 5 activity extension +├── KTestEnvironmentOptions.kt # Configuration options +├── KWorker.kt # Kotlin worker wrapper +├── WorkflowInitialTime.kt # Annotation for test initial time +└── internal/ + └── KTestActivityEnvironmentInternal.kt # Internal implementation +``` + +--- + +## 1. KTestEnvironmentOptions + +**Purpose:** Configuration options for test environments with DSL support. + +### Implementation + +```kotlin +package io.temporal.kotlin.testing + +import com.uber.m3.tally.Scope +import io.temporal.api.enums.v1.IndexedValueType +import io.temporal.client.WorkflowClientOptions +import io.temporal.kotlin.TemporalDsl +import io.temporal.serviceclient.WorkflowServiceStubsOptions +import io.temporal.testing.TestEnvironmentOptions +import io.temporal.worker.WorkerFactoryOptions +import java.time.Instant + +/** + * Kotlin DSL builder for test environment options. + */ +@TemporalDsl +public class KTestEnvironmentOptionsBuilder internal constructor() { + + /** Namespace to use for testing. Default: "UnitTest" */ + public var namespace: String = "UnitTest" + + /** Initial time for the workflow virtual clock. Default: current time */ + public var initialTime: Instant? = null + + /** Whether to enable time skipping. Default: true */ + public var useTimeskipping: Boolean = true + + /** Whether to use external Temporal service. Default: false (in-memory) */ + public var useExternalService: Boolean = false + + /** Target endpoint for external service. */ + public var target: String? = null + + /** Metrics scope for reporting. */ + public var metricsScope: Scope? = null + + private var workerFactoryOptionsBuilder: (WorkerFactoryOptions.Builder.() -> Unit)? = null + private var workflowClientOptionsBuilder: (WorkflowClientOptions.Builder.() -> Unit)? = null + private var workflowServiceStubsOptionsBuilder: (WorkflowServiceStubsOptions.Builder.() -> Unit)? = null + private val searchAttributes: MutableMap = mutableMapOf() + + /** + * Configure WorkerFactoryOptions. + */ + public fun workerFactoryOptions(block: WorkerFactoryOptions.Builder.() -> Unit) { + workerFactoryOptionsBuilder = block + } + + /** + * Configure WorkflowClientOptions. + */ + public fun workflowClientOptions(block: WorkflowClientOptions.Builder.() -> Unit) { + workflowClientOptionsBuilder = block + } + + /** + * Configure WorkflowServiceStubsOptions. + */ + public fun workflowServiceStubsOptions(block: WorkflowServiceStubsOptions.Builder.() -> Unit) { + workflowServiceStubsOptionsBuilder = block + } + + /** + * Register search attributes. + */ + public fun searchAttributes(block: SearchAttributesBuilder.() -> Unit) { + SearchAttributesBuilder(searchAttributes).apply(block) + } + + @TemporalDsl + public class SearchAttributesBuilder internal constructor( + private val attributes: MutableMap + ) { + public fun register(name: String, type: IndexedValueType) { + attributes[name] = type + } + } + + internal fun build(): TestEnvironmentOptions { + val builder = TestEnvironmentOptions.newBuilder() + + // Apply namespace via WorkflowClientOptions + val clientOptions = WorkflowClientOptions.newBuilder().apply { + setNamespace(namespace) + workflowClientOptionsBuilder?.invoke(this) + }.build() + builder.setWorkflowClientOptions(clientOptions) + + // Apply other options + workerFactoryOptionsBuilder?.let { block -> + builder.setWorkerFactoryOptions( + WorkerFactoryOptions.newBuilder().apply(block).build() + ) + } + + workflowServiceStubsOptionsBuilder?.let { block -> + builder.setWorkflowServiceStubsOptions( + WorkflowServiceStubsOptions.newBuilder().apply(block).build() + ) + } + + initialTime?.let { builder.setInitialTime(it) } + builder.setUseTimeskipping(useTimeskipping) + builder.setUseExternalService(useExternalService) + target?.let { builder.setTarget(it) } + metricsScope?.let { builder.setMetricsScope(it) } + + searchAttributes.forEach { (name, type) -> + builder.registerSearchAttribute(name, type) + } + + return builder.build() + } +} + +/** + * Immutable configuration for test environments. + */ +public class KTestEnvironmentOptions private constructor( + internal val javaOptions: TestEnvironmentOptions +) { + + public companion object { + /** + * Create options using DSL builder. + */ + public fun newBuilder(block: KTestEnvironmentOptionsBuilder.() -> Unit = {}): KTestEnvironmentOptions { + return KTestEnvironmentOptions( + KTestEnvironmentOptionsBuilder().apply(block).build() + ) + } + + /** + * Get default options. + */ + public fun getDefaultInstance(): KTestEnvironmentOptions { + return KTestEnvironmentOptions(TestEnvironmentOptions.getDefaultInstance()) + } + } +} +``` + +### Key Design Decisions + +1. **DSL Builder Pattern**: Uses `@TemporalDsl` annotation for type-safe DSL +2. **Wraps Java Options**: Internally converts to `TestEnvironmentOptions` +3. **Nested DSL Builders**: Supports nested configuration for search attributes + +--- + +## 2. KWorker + +**Purpose:** Kotlin wrapper for Worker with idiomatic registration APIs. + +### Implementation + +```kotlin +package io.temporal.kotlin.testing + +import io.temporal.kotlin.TemporalDsl +import io.temporal.kotlin.activity.SuspendActivityWrapper +import io.temporal.worker.Worker +import io.temporal.worker.WorkerOptions +import io.temporal.worker.WorkflowImplementationOptions +import kotlin.reflect.KClass + +/** + * Kotlin worker that provides idiomatic APIs for registering + * Kotlin workflows and suspend activities. + * + * Use [worker] property for direct access to the underlying Java Worker + * when interoperating with Java workflows/activities. + */ +public class KWorker internal constructor( + /** The underlying Java Worker for interop scenarios */ + public val worker: Worker +) { + + /** + * Register Kotlin workflow implementation types using reified generics. + */ + public inline fun registerWorkflowImplementationTypes() { + worker.registerWorkflowImplementationTypes(T::class.java) + } + + /** + * Register Kotlin workflow implementation types using KClass. + */ + public fun registerWorkflowImplementationTypes(vararg workflowClasses: KClass<*>) { + worker.registerWorkflowImplementationTypes( + *workflowClasses.map { it.java }.toTypedArray() + ) + } + + /** + * Register Kotlin workflow implementation types with options using KClass. + */ + public fun registerWorkflowImplementationTypes( + options: WorkflowImplementationOptions, + vararg workflowClasses: KClass<*> + ) { + worker.registerWorkflowImplementationTypes( + options, + *workflowClasses.map { it.java }.toTypedArray() + ) + } + + /** + * Register Kotlin workflow implementation types with options DSL. + */ + public inline fun registerWorkflowImplementationTypes( + options: WorkflowImplementationOptions.Builder.() -> Unit + ) { + val opts = WorkflowImplementationOptions.newBuilder().apply(options).build() + worker.registerWorkflowImplementationTypes(opts, T::class.java) + } + + /** + * Register activity implementations. + * Works with both regular and suspend activity implementations. + */ + public fun registerActivitiesImplementations(vararg activities: Any) { + worker.registerActivitiesImplementations(*activities) + } + + /** + * Register suspend activity implementations. + * Wraps suspend functions for execution in the Temporal activity context. + * + * @param activities Activity implementation objects containing suspend functions + */ + public fun registerSuspendActivities(vararg activities: Any) { + activities.forEach { activity -> + val wrapper = SuspendActivityWrapper.wrap(activity) + worker.registerActivitiesImplementations(wrapper) + } + } + + /** + * Register Nexus service implementations. + */ + public fun registerNexusServiceImplementations(vararg services: Any) { + worker.registerNexusServiceImplementation(*services) + } +} +``` + +### Key Design Decisions + +1. **Exposes Underlying Worker**: Via `worker` property for interop +2. **Reified Generics**: For type-safe registration without reflection +3. **KClass Support**: For vararg registration patterns +4. **Suspend Activity Support**: Via `SuspendActivityWrapper` integration + +--- + +## 3. KTestWorkflowEnvironment + +**Purpose:** Main test environment providing in-memory Temporal service with time skipping. + +### Implementation + +```kotlin +package io.temporal.kotlin.testing + +import io.temporal.api.enums.v1.IndexedValueType +import io.temporal.api.nexus.v1.Endpoint +import io.temporal.kotlin.client.KWorkflowClient +import io.temporal.kotlin.toJavaDuration +import io.temporal.kotlin.worker.KotlinPlugin +import io.temporal.serviceclient.OperatorServiceStubs +import io.temporal.serviceclient.WorkflowServiceStubs +import io.temporal.testing.TestWorkflowEnvironment +import io.temporal.worker.Worker +import io.temporal.worker.WorkerOptions +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.Closeable +import java.time.Duration +import java.time.Instant +import java.util.concurrent.TimeUnit +import kotlin.time.Duration as KDuration +import kotlin.time.toJavaDuration as kotlinToJava + +/** + * Kotlin test environment for workflow unit testing. + * + * Provides an in-memory Temporal service with automatic time skipping, + * allowing workflows that run for hours/days to be tested in milliseconds. + * + * Example: + * ```kotlin + * val testEnv = KTestWorkflowEnvironment.newInstance { + * namespace = "test-namespace" + * initialTime = Instant.parse("2024-01-01T00:00:00Z") + * } + * + * val worker = testEnv.newWorker("task-queue") + * worker.registerWorkflowImplementationTypes() + * worker.registerActivitiesImplementations(MyActivitiesImpl()) + * + * testEnv.start() + * + * val result = testEnv.workflowClient.executeWorkflow( + * MyWorkflow::execute, + * KWorkflowOptions(taskQueue = "task-queue"), + * "input" + * ) + * + * testEnv.close() + * ``` + */ +public class KTestWorkflowEnvironment private constructor( + private val testEnvironment: TestWorkflowEnvironment +) : Closeable { + + /** + * The Kotlin workflow client for interacting with workflows. + */ + public val workflowClient: KWorkflowClient by lazy { + KWorkflowClient(testEnvironment.workflowClient) + } + + /** + * Current test time in milliseconds since epoch. + * May differ from system time due to time skipping. + */ + public val currentTimeMillis: Long + get() = testEnvironment.currentTimeMillis() + + /** + * Current test time as an Instant. + */ + public val currentTime: Instant + get() = Instant.ofEpochMilli(currentTimeMillis) + + /** + * The namespace used by this test environment. + */ + public val namespace: String + get() = testEnvironment.namespace + + /** + * Whether the workers have been started. + */ + public val isStarted: Boolean + get() = testEnvironment.isStarted + + /** + * Whether shutdown has been initiated. + */ + public val isShutdown: Boolean + get() = testEnvironment.isShutdown + + /** + * Whether all workers have terminated. + */ + public val isTerminated: Boolean + get() = testEnvironment.isTerminated + + /** + * Access to WorkflowServiceStubs for advanced scenarios. + */ + public val workflowServiceStubs: WorkflowServiceStubs + get() = testEnvironment.workflowServiceStubs + + /** + * Access to OperatorServiceStubs for advanced scenarios. + */ + public val operatorServiceStubs: OperatorServiceStubs + get() = testEnvironment.operatorServiceStubs + + /** + * Create a new Kotlin worker for the specified task queue. + * + * @param taskQueue The task queue name + * @return A KWorker instance + */ + public fun newWorker(taskQueue: String): KWorker { + return KWorker(testEnvironment.newWorker(taskQueue)) + } + + /** + * Create a new Kotlin worker with options. + * + * @param taskQueue The task queue name + * @param options DSL builder for WorkerOptions + * @return A KWorker instance + */ + public fun newWorker( + taskQueue: String, + options: WorkerOptions.Builder.() -> Unit + ): KWorker { + val workerOptions = WorkerOptions.newBuilder().apply(options).build() + return KWorker(testEnvironment.newWorker(taskQueue, workerOptions)) + } + + /** + * Create a new Kotlin worker with WorkerOptions. + * + * @param taskQueue The task queue name + * @param options WorkerOptions instance + * @return A KWorker instance + */ + public fun newWorker(taskQueue: String, options: WorkerOptions): KWorker { + return KWorker(testEnvironment.newWorker(taskQueue, options)) + } + + /** + * Start all registered workers. + */ + public fun start() { + testEnvironment.start() + } + + /** + * Sleep for the specified duration with time skipping. + * This is a suspend function that advances test time without blocking. + * + * @param duration Kotlin Duration to sleep + */ + public suspend fun sleep(duration: KDuration) { + withContext(Dispatchers.IO) { + testEnvironment.sleep(duration.kotlinToJava()) + } + } + + /** + * Sleep for the specified duration with time skipping. + * + * @param duration Java Duration to sleep + */ + public suspend fun sleep(duration: Duration) { + withContext(Dispatchers.IO) { + testEnvironment.sleep(duration) + } + } + + /** + * Register a callback to execute after the specified delay in test time. + * + * @param delay Kotlin Duration delay + * @param callback The callback to execute + */ + public fun registerDelayedCallback(delay: KDuration, callback: () -> Unit) { + testEnvironment.registerDelayedCallback(delay.kotlinToJava()) { callback() } + } + + /** + * Register a callback to execute after the specified delay in test time. + * + * @param delay Java Duration delay + * @param callback The callback to execute + */ + public fun registerDelayedCallback(delay: Duration, callback: () -> Unit) { + testEnvironment.registerDelayedCallback(delay) { callback() } + } + + /** + * Register a search attribute with the test server. + * + * @param name Search attribute name + * @param type Search attribute type + * @return true if registered, false if already exists + */ + public fun registerSearchAttribute(name: String, type: IndexedValueType): Boolean { + return testEnvironment.registerSearchAttribute(name, type) + } + + /** + * Create a Nexus endpoint for testing. + * + * @param name Endpoint name + * @param taskQueue Task queue for the endpoint + * @return The created Endpoint + */ + public fun createNexusEndpoint(name: String, taskQueue: String): Endpoint { + return testEnvironment.createNexusEndpoint(name, taskQueue) + } + + /** + * Delete a Nexus endpoint. + * + * @param endpoint The endpoint to delete + */ + public fun deleteNexusEndpoint(endpoint: Endpoint) { + testEnvironment.deleteNexusEndpoint(endpoint) + } + + /** + * Get diagnostic information including workflow histories. + * Useful for debugging test failures. + */ + public fun getDiagnostics(): String { + return testEnvironment.diagnostics + } + + /** + * Initiate graceful shutdown. + * Workers stop accepting new tasks but complete in-progress work. + */ + public fun shutdown() { + testEnvironment.shutdown() + } + + /** + * Initiate immediate shutdown. + * Attempts to stop all processing immediately. + */ + public fun shutdownNow() { + testEnvironment.shutdownNow() + } + + /** + * Wait for all workers to terminate. + * + * @param timeout Maximum time to wait + */ + public suspend fun awaitTermination(timeout: KDuration) { + withContext(Dispatchers.IO) { + testEnvironment.awaitTermination( + timeout.inWholeMilliseconds, + TimeUnit.MILLISECONDS + ) + } + } + + /** + * Wait for all workers to terminate. + * + * @param timeout Maximum time to wait (Java Duration) + */ + public suspend fun awaitTermination(timeout: Duration) { + withContext(Dispatchers.IO) { + testEnvironment.awaitTermination( + timeout.toMillis(), + TimeUnit.MILLISECONDS + ) + } + } + + /** + * Close the test environment. + * Calls shutdownNow() and awaitTermination(). + */ + override fun close() { + testEnvironment.close() + } + + public companion object { + /** + * Create a new test environment with default options. + */ + public fun newInstance(): KTestWorkflowEnvironment { + return KTestWorkflowEnvironment(TestWorkflowEnvironment.newInstance()) + } + + /** + * Create a new test environment with DSL configuration. + * + * Example: + * ```kotlin + * val testEnv = KTestWorkflowEnvironment.newInstance { + * namespace = "test-namespace" + * initialTime = Instant.parse("2024-01-01T00:00:00Z") + * useTimeskipping = true + * + * workerFactoryOptions { + * maxWorkflowThreadCount = 800 + * } + * + * searchAttributes { + * register("CustomKeyword", IndexedValueType.INDEXED_VALUE_TYPE_KEYWORD) + * } + * } + * ``` + */ + public fun newInstance( + options: KTestEnvironmentOptionsBuilder.() -> Unit + ): KTestWorkflowEnvironment { + val javaOptions = KTestEnvironmentOptionsBuilder().apply(options).build() + return KTestWorkflowEnvironment(TestWorkflowEnvironment.newInstance(javaOptions)) + } + + /** + * Create a new test environment with pre-built options. + */ + public fun newInstance(options: KTestEnvironmentOptions): KTestWorkflowEnvironment { + return KTestWorkflowEnvironment( + TestWorkflowEnvironment.newInstance(options.javaOptions) + ) + } + } +} + +/** + * Use the test environment with automatic cleanup. + * + * Example: + * ```kotlin + * KTestWorkflowEnvironment.newInstance().use { testEnv -> + * // Test code here + * } // Automatically closed + * ``` + */ +public inline fun KTestWorkflowEnvironment.use(block: (KTestWorkflowEnvironment) -> T): T { + return try { + block(this) + } finally { + close() + } +} +``` + +### Key Design Decisions + +1. **Wraps TestWorkflowEnvironment**: Provides Kotlin-idiomatic API over Java implementation +2. **Suspend Functions**: `sleep()` and `awaitTermination()` are suspend functions +3. **Dual Duration Support**: Accepts both `kotlin.time.Duration` and `java.time.Duration` +4. **Property Access**: Uses Kotlin properties instead of getter methods +5. **DSL Builder**: Static `newInstance { }` DSL for configuration +6. **KWorkflowClient Integration**: Returns `KWorkflowClient` for workflow operations +7. **KWorker Return**: `newWorker()` returns `KWorker` instead of Java `Worker` + +--- + +## 4. WorkflowInitialTime Annotation + +**Purpose:** Override initial time for specific test methods. + +### Implementation + +```kotlin +package io.temporal.kotlin.testing + +/** + * Annotation to specify the initial time for a workflow test. + * Overrides the initial time configured in the extension. + * + * Example: + * ```kotlin + * @Test + * @WorkflowInitialTime("2024-06-15T12:00:00Z") + * fun `test with specific initial time`(workflow: MyWorkflow) { + * // Test runs with June 15, 2024 as initial time + * } + * ``` + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +public annotation class WorkflowInitialTime( + /** + * ISO-8601 formatted timestamp for the initial time. + * Example: "2024-01-01T00:00:00Z" + */ + val value: String +) +``` + +--- + +## 5. KTestWorkflowExtension + +**Purpose:** JUnit 5 extension for simplified workflow testing. + +### Implementation + +```kotlin +package io.temporal.kotlin.testing + +import io.temporal.api.enums.v1.IndexedValueType +import io.temporal.client.WorkflowClientOptions +import io.temporal.client.WorkflowOptions +import io.temporal.common.metadata.POJOWorkflowImplMetadata +import io.temporal.common.metadata.POJOWorkflowInterfaceMetadata +import io.temporal.kotlin.TemporalDsl +import io.temporal.kotlin.client.KWorkflowClient +import io.temporal.kotlin.client.KWorkflowOptions +import io.temporal.testing.TestWorkflowEnvironment +import io.temporal.worker.Worker +import io.temporal.worker.WorkerFactoryOptions +import io.temporal.worker.WorkerOptions +import io.temporal.worker.WorkflowImplementationOptions +import io.temporal.workflow.DynamicWorkflow +import org.junit.jupiter.api.extension.* +import org.junit.platform.commons.support.AnnotationSupport +import java.lang.reflect.Constructor +import java.lang.reflect.Parameter +import java.time.Instant +import java.util.* +import kotlin.reflect.KClass + +/** + * JUnit 5 extension for testing Temporal workflows with Kotlin-idiomatic APIs. + * + * Example: + * ```kotlin + * class MyWorkflowTest { + * companion object { + * @JvmField + * @RegisterExtension + * val testWorkflow = kTestWorkflowExtension { + * registerWorkflowImplementationTypes() + * setActivityImplementations(MyActivitiesImpl()) + * } + * } + * + * @Test + * fun `test workflow execution`(workflow: MyWorkflow) { + * val result = workflow.execute("input") + * assertEquals("expected", result) + * } + * } + * ``` + */ +public class KTestWorkflowExtension private constructor( + private val config: ExtensionConfig +) : ParameterResolver, TestWatcher, BeforeEachCallback, AfterEachCallback { + + private data class ExtensionConfig( + val namespace: String, + val workflowTypes: Map, WorkflowImplementationOptions>, + val activityImplementations: Array, + val suspendActivityImplementations: Array, + val nexusServiceImplementations: Array, + val workerOptions: WorkerOptions, + val workerFactoryOptions: WorkerFactoryOptions?, + val workflowClientOptions: WorkflowClientOptions?, + val useExternalService: Boolean, + val target: String?, + val doNotStart: Boolean, + val initialTimeMillis: Long, + val useTimeskipping: Boolean, + val searchAttributes: Map + ) + + private val supportedParameterTypes = mutableSetOf>().apply { + add(KTestWorkflowEnvironment::class.java) + add(KWorkflowClient::class.java) + add(KWorkflowOptions::class.java) + add(KWorker::class.java) + add(Worker::class.java) + // Add workflow interface types + config.workflowTypes.keys.forEach { workflowType -> + if (!DynamicWorkflow::class.java.isAssignableFrom(workflowType)) { + val metadata = POJOWorkflowImplMetadata.newInstance(workflowType) + metadata.workflowInterfaces.forEach { add(it.interfaceClass) } + } + } + } + + private val includesDynamicWorkflow = config.workflowTypes.keys.any { + DynamicWorkflow::class.java.isAssignableFrom(it) + } + + override fun supportsParameter( + parameterContext: ParameterContext, + extensionContext: ExtensionContext + ): Boolean { + val parameter = parameterContext.parameter + if (parameter.declaringExecutable is Constructor<*>) return false + + val parameterType = parameter.type + if (supportedParameterTypes.contains(parameterType)) return true + + if (!includesDynamicWorkflow) return false + + return try { + POJOWorkflowInterfaceMetadata.newInstance(parameterType) + true + } catch (e: Exception) { + false + } + } + + override fun resolveParameter( + parameterContext: ParameterContext, + extensionContext: ExtensionContext + ): Any { + val parameterType = parameterContext.parameter.type + val store = getStore(extensionContext) + + return when (parameterType) { + KTestWorkflowEnvironment::class.java -> getTestEnvironment(store) + KWorkflowClient::class.java -> getTestEnvironment(store).workflowClient + KWorkflowOptions::class.java -> getWorkflowOptions(store) + KWorker::class.java -> getKWorker(store) + Worker::class.java -> getKWorker(store).worker + else -> { + // Create workflow stub + val testEnv = getTestEnvironment(store) + val options = getWorkflowOptions(store) + testEnv.workflowClient.workflowClient.newWorkflowStub( + parameterType, + options.toJavaOptions() + ) + } + } + } + + override fun beforeEach(context: ExtensionContext) { + val currentInitialTimeMillis = AnnotationSupport.findAnnotation( + context.element, + WorkflowInitialTime::class.java + ).map { Instant.parse(it.value).toEpochMilli() } + .orElse(config.initialTimeMillis) + + val testEnvOptions = io.temporal.testing.TestEnvironmentOptions.newBuilder().apply { + setUseExternalService(config.useExternalService) + setUseTimeskipping(config.useTimeskipping) + config.target?.let { setTarget(it) } + if (currentInitialTimeMillis > 0) setInitialTimeMillis(currentInitialTimeMillis) + + val clientOptions = (config.workflowClientOptions?.let { + WorkflowClientOptions.newBuilder(it) + } ?: WorkflowClientOptions.newBuilder()) + .setNamespace(config.namespace) + .build() + setWorkflowClientOptions(clientOptions) + + config.workerFactoryOptions?.let { setWorkerFactoryOptions(it) } + config.searchAttributes.forEach { (name, type) -> + registerSearchAttribute(name, type) + } + }.build() + + val javaTestEnv = TestWorkflowEnvironment.newInstance(testEnvOptions) + val testEnvironment = KTestWorkflowEnvironment.newInstance { + // Options already applied to javaTestEnv + } + + // Create task queue + val taskQueue = "WorkflowTest-${context.displayName}-${context.uniqueId}" + val worker = javaTestEnv.newWorker(taskQueue, config.workerOptions) + val kWorker = KWorker(worker) + + // Register workflows + config.workflowTypes.forEach { (wfType, options) -> + worker.registerWorkflowImplementationTypes(options, wfType) + } + + // Register activities + worker.registerActivitiesImplementations(*config.activityImplementations) + + // Register suspend activities + config.suspendActivityImplementations.forEach { activity -> + kWorker.registerSuspendActivities(activity) + } + + // Register Nexus services + if (config.nexusServiceImplementations.isNotEmpty()) { + worker.registerNexusServiceImplementation(*config.nexusServiceImplementations) + } + + if (!config.doNotStart) { + javaTestEnv.start() + } + + // Store in extension context + val store = getStore(context) + store.put(TEST_ENVIRONMENT_KEY, KTestWorkflowEnvironment.Companion::class.java.getDeclaredMethod( + "newInstance" + ).let { + // Wrap the Java test environment + val field = KTestWorkflowEnvironment::class.java.getDeclaredField("testEnvironment") + field.isAccessible = true + KTestWorkflowEnvironment::class.java.getDeclaredConstructor( + TestWorkflowEnvironment::class.java + ).apply { isAccessible = true }.newInstance(javaTestEnv) + }) + store.put(KWORKER_KEY, kWorker) + store.put(WORKFLOW_OPTIONS_KEY, KWorkflowOptions(taskQueue = taskQueue)) + } + + override fun afterEach(context: ExtensionContext) { + val testEnv = getStore(context).get(TEST_ENVIRONMENT_KEY, KTestWorkflowEnvironment::class.java) + testEnv?.close() + } + + override fun testFailed(context: ExtensionContext, cause: Throwable) { + val testEnv = getStore(context).get(TEST_ENVIRONMENT_KEY, KTestWorkflowEnvironment::class.java) + testEnv?.let { + System.err.println("Workflow execution histories:\n${it.getDiagnostics()}") + } + } + + private fun getStore(context: ExtensionContext): ExtensionContext.Store { + val namespace = ExtensionContext.Namespace.create( + KTestWorkflowExtension::class.java, + context.requiredTestMethod + ) + return context.getStore(namespace) + } + + private fun getTestEnvironment(store: ExtensionContext.Store): KTestWorkflowEnvironment { + return store.get(TEST_ENVIRONMENT_KEY, KTestWorkflowEnvironment::class.java) + ?: throw IllegalStateException("Test environment not initialized") + } + + private fun getKWorker(store: ExtensionContext.Store): KWorker { + return store.get(KWORKER_KEY, KWorker::class.java) + ?: throw IllegalStateException("Worker not initialized") + } + + private fun getWorkflowOptions(store: ExtensionContext.Store): KWorkflowOptions { + return store.get(WORKFLOW_OPTIONS_KEY, KWorkflowOptions::class.java) + ?: throw IllegalStateException("Workflow options not initialized") + } + + public companion object { + private const val TEST_ENVIRONMENT_KEY = "testEnvironment" + private const val KWORKER_KEY = "kWorker" + private const val WORKFLOW_OPTIONS_KEY = "workflowOptions" + + /** + * Create a new extension builder. + */ + public fun newBuilder(): Builder = Builder() + } + + /** + * Builder for KTestWorkflowExtension. + */ + @TemporalDsl + public class Builder internal constructor() { + private var namespace: String = "UnitTest" + private val workflowTypes = mutableMapOf, WorkflowImplementationOptions>() + private var activityImplementations: Array = emptyArray() + private var suspendActivityImplementations: Array = emptyArray() + private var nexusServiceImplementations: Array = emptyArray() + private var workerOptions: WorkerOptions = WorkerOptions.getDefaultInstance() + private var workerFactoryOptions: WorkerFactoryOptions? = null + private var workflowClientOptions: WorkflowClientOptions? = null + private var useExternalService: Boolean = false + private var target: String? = null + private var doNotStart: Boolean = false + private var initialTimeMillis: Long = 0 + private var useTimeskipping: Boolean = true + private val searchAttributes = mutableMapOf() + + /** Set the namespace. Default: "UnitTest" */ + public var namespace_: String + get() = namespace + set(value) { namespace = value } + + /** Initial time for the test. */ + public var initialTime: Instant? = null + set(value) { + field = value + initialTimeMillis = value?.toEpochMilli() ?: 0 + } + + /** Whether to enable time skipping. Default: true */ + public var useTimeskipping_: Boolean + get() = useTimeskipping + set(value) { useTimeskipping = value } + + /** Whether to defer worker startup. Default: false */ + public var doNotStart_: Boolean + get() = doNotStart + set(value) { doNotStart = value } + + /** + * Register workflow implementation types using reified generics. + */ + public inline fun registerWorkflowImplementationTypes() { + workflowTypes[T::class.java] = WorkflowImplementationOptions.newBuilder().build() + } + + /** + * Register workflow implementation types with options DSL. + */ + public inline fun registerWorkflowImplementationTypes( + options: WorkflowImplementationOptions.Builder.() -> Unit + ) { + workflowTypes[T::class.java] = WorkflowImplementationOptions.newBuilder().apply(options).build() + } + + /** + * Register workflow implementation types. + */ + public fun registerWorkflowImplementationTypes(vararg classes: Class<*>) { + val defaultOptions = WorkflowImplementationOptions.newBuilder().build() + classes.forEach { workflowTypes[it] = defaultOptions } + } + + /** + * Register workflow implementation types with options. + */ + public fun registerWorkflowImplementationTypes( + options: WorkflowImplementationOptions, + vararg classes: Class<*> + ) { + classes.forEach { workflowTypes[it] = options } + } + + /** + * Set activity implementations. + */ + public fun setActivityImplementations(vararg activities: Any) { + activityImplementations = arrayOf(*activities) + } + + /** + * Set suspend activity implementations. + */ + public fun setSuspendActivityImplementations(vararg activities: Any) { + suspendActivityImplementations = arrayOf(*activities) + } + + /** + * Set Nexus service implementations. + */ + public fun setNexusServiceImplementations(vararg services: Any) { + nexusServiceImplementations = arrayOf(*services) + } + + /** + * Configure worker options. + */ + public fun workerOptions(block: WorkerOptions.Builder.() -> Unit) { + workerOptions = WorkerOptions.newBuilder().apply(block).build() + } + + /** + * Configure worker factory options. + */ + public fun workerFactoryOptions(block: WorkerFactoryOptions.Builder.() -> Unit) { + workerFactoryOptions = WorkerFactoryOptions.newBuilder().apply(block).build() + } + + /** + * Configure workflow client options. + */ + public fun workflowClientOptions(block: WorkflowClientOptions.Builder.() -> Unit) { + workflowClientOptions = WorkflowClientOptions.newBuilder().apply(block).build() + } + + /** + * Use internal in-memory service (default). + */ + public fun useInternalService() { + useExternalService = false + target = null + } + + /** + * Use external Temporal service with ClientConfigProfile. + */ + public fun useExternalService() { + useExternalService = true + target = null + } + + /** + * Use external Temporal service with explicit address. + */ + public fun useExternalService(address: String) { + useExternalService = true + target = address + } + + /** + * Configure search attributes. + */ + public fun searchAttributes(block: SearchAttributesBuilder.() -> Unit) { + SearchAttributesBuilder(searchAttributes).apply(block) + } + + @TemporalDsl + public class SearchAttributesBuilder internal constructor( + private val attributes: MutableMap + ) { + public fun register(name: String, type: IndexedValueType) { + attributes[name] = type + } + } + + public fun build(): KTestWorkflowExtension { + return KTestWorkflowExtension( + ExtensionConfig( + namespace = namespace, + workflowTypes = workflowTypes.toMap(), + activityImplementations = activityImplementations, + suspendActivityImplementations = suspendActivityImplementations, + nexusServiceImplementations = nexusServiceImplementations, + workerOptions = workerOptions, + workerFactoryOptions = workerFactoryOptions, + workflowClientOptions = workflowClientOptions, + useExternalService = useExternalService, + target = target, + doNotStart = doNotStart, + initialTimeMillis = initialTimeMillis, + useTimeskipping = useTimeskipping, + searchAttributes = searchAttributes.toMap() + ) + ) + } + } +} + +/** + * DSL function to create a KTestWorkflowExtension. + * + * Example: + * ```kotlin + * companion object { + * @JvmField + * @RegisterExtension + * val testWorkflow = kTestWorkflowExtension { + * registerWorkflowImplementationTypes() + * setActivityImplementations(MyActivitiesImpl()) + * } + * } + * ``` + */ +public fun kTestWorkflowExtension( + block: KTestWorkflowExtension.Builder.() -> Unit +): KTestWorkflowExtension { + return KTestWorkflowExtension.newBuilder().apply(block).build() +} +``` + +### Key Design Decisions + +1. **DSL Function**: `kTestWorkflowExtension { }` top-level function +2. **Reified Generics**: `registerWorkflowImplementationTypes()` for type-safe registration +3. **Parameter Injection**: Supports `KTestWorkflowEnvironment`, `KWorkflowClient`, `KWorkflowOptions`, `KWorker`, `Worker`, and workflow stubs +4. **Initial Time Annotation**: Supports `@WorkflowInitialTime` per-test override +5. **Test Failure Diagnostics**: Automatically prints workflow histories on failure +6. **External Service Support**: `useExternalService()` and `useExternalService(address)` + +--- + +## 6. KTestActivityEnvironment + +**Purpose:** Environment for unit testing activity implementations in isolation. + +### Implementation + +```kotlin +package io.temporal.kotlin.testing + +import io.temporal.activity.ActivityOptions +import io.temporal.activity.LocalActivityOptions +import io.temporal.kotlin.TemporalDsl +import io.temporal.kotlin.activity.KActivityOptions +import io.temporal.kotlin.activity.KLocalActivityOptions +import io.temporal.kotlin.activity.SuspendActivityWrapper +import io.temporal.testing.TestActivityEnvironment +import io.temporal.workflow.Functions +import java.io.Closeable +import java.lang.reflect.Type +import kotlin.reflect.KFunction1 +import kotlin.reflect.KFunction2 +import kotlin.reflect.KFunction3 +import kotlin.reflect.KFunction4 +import kotlin.reflect.KSuspendFunction1 +import kotlin.reflect.KSuspendFunction2 +import kotlin.reflect.KSuspendFunction3 +import kotlin.reflect.KSuspendFunction4 +import kotlin.reflect.jvm.javaMethod + +/** + * Kotlin test environment for activity unit testing. + * + * Supports testing both regular and suspend activity implementations + * in isolation without needing workflows. + * + * Example: + * ```kotlin + * val activityEnv = KTestActivityEnvironment.newInstance() + * activityEnv.registerActivitiesImplementations(MyActivitiesImpl()) + * + * val result = activityEnv.executeActivity( + * MyActivities::doSomething, + * KActivityOptions(startToCloseTimeout = 30.seconds), + * "input" + * ) + * + * assertEquals("expected", result) + * activityEnv.close() + * ``` + */ +public class KTestActivityEnvironment private constructor( + private val testEnvironment: TestActivityEnvironment +) : Closeable { + + /** + * Register activity implementations. + */ + public fun registerActivitiesImplementations(vararg activities: Any) { + testEnvironment.registerActivitiesImplementations(*activities) + } + + /** + * Register suspend activity implementations. + */ + public fun registerSuspendActivities(vararg activities: Any) { + activities.forEach { activity -> + val wrapper = SuspendActivityWrapper.wrap(activity) + testEnvironment.registerActivitiesImplementations(wrapper) + } + } + + // ========== Execute Activity (1-4 args) ========== + + /** + * Execute an activity with no arguments. + */ + public fun executeActivity( + activity: KFunction1, + options: KActivityOptions + ): R { + val stub = createActivityStub(activity, options.toJavaOptions()) + @Suppress("UNCHECKED_CAST") + return activity.call(stub) as R + } + + /** + * Execute an activity with one argument. + */ + public fun executeActivity( + activity: KFunction2, + options: KActivityOptions, + arg1: A1 + ): R { + val stub = createActivityStub(activity, options.toJavaOptions()) + @Suppress("UNCHECKED_CAST") + return activity.call(stub, arg1) as R + } + + /** + * Execute an activity with two arguments. + */ + public fun executeActivity( + activity: KFunction3, + options: KActivityOptions, + arg1: A1, + arg2: A2 + ): R { + val stub = createActivityStub(activity, options.toJavaOptions()) + @Suppress("UNCHECKED_CAST") + return activity.call(stub, arg1, arg2) as R + } + + /** + * Execute an activity with three arguments. + */ + public fun executeActivity( + activity: KFunction4, + options: KActivityOptions, + arg1: A1, + arg2: A2, + arg3: A3 + ): R { + val stub = createActivityStub(activity, options.toJavaOptions()) + @Suppress("UNCHECKED_CAST") + return activity.call(stub, arg1, arg2, arg3) as R + } + + // ========== Execute Suspend Activity (1-4 args) ========== + + /** + * Execute a suspend activity with no arguments. + */ + @JvmName("executeSuspendActivity0") + public suspend fun executeActivity( + activity: KSuspendFunction1, + options: KActivityOptions + ): R { + val stub = createActivityStub(activity, options.toJavaOptions()) + @Suppress("UNCHECKED_CAST") + return activity.call(stub) as R + } + + /** + * Execute a suspend activity with one argument. + */ + @JvmName("executeSuspendActivity1") + public suspend fun executeActivity( + activity: KSuspendFunction2, + options: KActivityOptions, + arg1: A1 + ): R { + val stub = createActivityStub(activity, options.toJavaOptions()) + @Suppress("UNCHECKED_CAST") + return activity.call(stub, arg1) as R + } + + /** + * Execute a suspend activity with two arguments. + */ + @JvmName("executeSuspendActivity2") + public suspend fun executeActivity( + activity: KSuspendFunction3, + options: KActivityOptions, + arg1: A1, + arg2: A2 + ): R { + val stub = createActivityStub(activity, options.toJavaOptions()) + @Suppress("UNCHECKED_CAST") + return activity.call(stub, arg1, arg2) as R + } + + /** + * Execute a suspend activity with three arguments. + */ + @JvmName("executeSuspendActivity3") + public suspend fun executeActivity( + activity: KSuspendFunction4, + options: KActivityOptions, + arg1: A1, + arg2: A2, + arg3: A3 + ): R { + val stub = createActivityStub(activity, options.toJavaOptions()) + @Suppress("UNCHECKED_CAST") + return activity.call(stub, arg1, arg2, arg3) as R + } + + // ========== Execute Local Activity (1-4 args) ========== + + /** + * Execute a local activity with no arguments. + */ + public fun executeLocalActivity( + activity: KFunction1, + options: KLocalActivityOptions + ): R { + val stub = createLocalActivityStub(activity, options.toJavaOptions()) + @Suppress("UNCHECKED_CAST") + return activity.call(stub) as R + } + + /** + * Execute a local activity with one argument. + */ + public fun executeLocalActivity( + activity: KFunction2, + options: KLocalActivityOptions, + arg1: A1 + ): R { + val stub = createLocalActivityStub(activity, options.toJavaOptions()) + @Suppress("UNCHECKED_CAST") + return activity.call(stub, arg1) as R + } + + /** + * Execute a local activity with two arguments. + */ + public fun executeLocalActivity( + activity: KFunction3, + options: KLocalActivityOptions, + arg1: A1, + arg2: A2 + ): R { + val stub = createLocalActivityStub(activity, options.toJavaOptions()) + @Suppress("UNCHECKED_CAST") + return activity.call(stub, arg1, arg2) as R + } + + /** + * Execute a local activity with three arguments. + */ + public fun executeLocalActivity( + activity: KFunction4, + options: KLocalActivityOptions, + arg1: A1, + arg2: A2, + arg3: A3 + ): R { + val stub = createLocalActivityStub(activity, options.toJavaOptions()) + @Suppress("UNCHECKED_CAST") + return activity.call(stub, arg1, arg2, arg3) as R + } + + // ========== Heartbeat Testing ========== + + /** + * Set heartbeat details for the next activity execution. + * Simulates activity retry with heartbeat checkpoint. + */ + public fun setHeartbeatDetails(details: T) { + testEnvironment.setHeartbeatDetails(details) + } + + /** + * Set a listener for activity heartbeats. + */ + public inline fun setActivityHeartbeatListener( + noinline listener: (T) -> Unit + ) { + testEnvironment.setActivityHeartbeatListener( + T::class.java, + Functions.Proc1 { listener(it) } + ) + } + + /** + * Set a listener for activity heartbeats with type information. + */ + public fun setActivityHeartbeatListener( + detailsClass: Class, + detailsType: Type, + listener: (T) -> Unit + ) { + testEnvironment.setActivityHeartbeatListener( + detailsClass, + detailsType, + Functions.Proc1 { listener(it) } + ) + } + + // ========== Cancellation Testing ========== + + /** + * Request cancellation of the currently executing activity. + * Cancellation is delivered on the next heartbeat. + */ + public fun requestCancelActivity() { + testEnvironment.requestCancelActivity() + } + + // ========== Lifecycle ========== + + override fun close() { + testEnvironment.close() + } + + // ========== Internal Helpers ========== + + @Suppress("UNCHECKED_CAST") + private fun createActivityStub( + activity: kotlin.reflect.KFunction<*>, + options: ActivityOptions + ): T { + val activityClass = activity.javaMethod?.declaringClass + ?: throw IllegalArgumentException("Cannot determine activity interface") + return testEnvironment.newActivityStub(activityClass, options) as T + } + + @Suppress("UNCHECKED_CAST") + private fun createLocalActivityStub( + activity: kotlin.reflect.KFunction<*>, + options: LocalActivityOptions + ): T { + val activityClass = activity.javaMethod?.declaringClass + ?: throw IllegalArgumentException("Cannot determine activity interface") + return testEnvironment.newLocalActivityStub(activityClass, options, emptyMap()) as T + } + + public companion object { + /** + * Create a new activity test environment with default options. + */ + public fun newInstance(): KTestActivityEnvironment { + return KTestActivityEnvironment(TestActivityEnvironment.newInstance()) + } + + /** + * Create a new activity test environment with DSL configuration. + */ + public fun newInstance( + options: KTestEnvironmentOptionsBuilder.() -> Unit + ): KTestActivityEnvironment { + val javaOptions = KTestEnvironmentOptionsBuilder().apply(options).build() + return KTestActivityEnvironment(TestActivityEnvironment.newInstance(javaOptions)) + } + + /** + * Create a new activity test environment with pre-built options. + */ + public fun newInstance(options: KTestEnvironmentOptions): KTestActivityEnvironment { + return KTestActivityEnvironment( + TestActivityEnvironment.newInstance(options.javaOptions) + ) + } + } +} +``` + +### Key Design Decisions + +1. **Method Reference API**: `executeActivity(MyActivity::method, options, args)` +2. **Suspend Function Support**: Separate overloads for suspend activities +3. **Local Activity Support**: `executeLocalActivity` methods +4. **Heartbeat Testing**: `setHeartbeatDetails` and `setActivityHeartbeatListener` +5. **Cancellation Testing**: `requestCancelActivity()` + +--- + +## 7. KTestActivityExtension + +**Purpose:** JUnit 5 extension for simplified activity testing. + +### Implementation + +```kotlin +package io.temporal.kotlin.testing + +import io.temporal.kotlin.TemporalDsl +import io.temporal.kotlin.activity.SuspendActivityWrapper +import io.temporal.testing.TestActivityEnvironment +import io.temporal.testing.TestEnvironmentOptions +import org.junit.jupiter.api.extension.* +import java.lang.reflect.Constructor + +/** + * JUnit 5 extension for testing Temporal activities. + * + * Example: + * ```kotlin + * class MyActivityTest { + * companion object { + * @JvmField + * @RegisterExtension + * val testActivity = kTestActivityExtension { + * setActivityImplementations(MyActivitiesImpl()) + * } + * } + * + * @Test + * fun `test activity`(activityEnv: KTestActivityEnvironment) { + * val result = activityEnv.executeActivity( + * MyActivities::doSomething, + * KActivityOptions(startToCloseTimeout = 30.seconds), + * "input" + * ) + * assertEquals("expected", result) + * } + * } + * ``` + */ +public class KTestActivityExtension private constructor( + private val config: ExtensionConfig +) : ParameterResolver, BeforeEachCallback, AfterEachCallback { + + private data class ExtensionConfig( + val testEnvironmentOptions: TestEnvironmentOptions, + val activityImplementations: Array, + val suspendActivityImplementations: Array + ) + + override fun supportsParameter( + parameterContext: ParameterContext, + extensionContext: ExtensionContext + ): Boolean { + val parameter = parameterContext.parameter + if (parameter.declaringExecutable is Constructor<*>) return false + return parameter.type == KTestActivityEnvironment::class.java + } + + override fun resolveParameter( + parameterContext: ParameterContext, + extensionContext: ExtensionContext + ): Any { + return getStore(extensionContext).get( + TEST_ENVIRONMENT_KEY, + KTestActivityEnvironment::class.java + ) ?: throw IllegalStateException("Activity environment not initialized") + } + + override fun beforeEach(context: ExtensionContext) { + val javaEnv = TestActivityEnvironment.newInstance(config.testEnvironmentOptions) + + // Register regular activities + javaEnv.registerActivitiesImplementations(*config.activityImplementations) + + // Register suspend activities + config.suspendActivityImplementations.forEach { activity -> + val wrapper = SuspendActivityWrapper.wrap(activity) + javaEnv.registerActivitiesImplementations(wrapper) + } + + val kEnv = KTestActivityEnvironment::class.java + .getDeclaredConstructor(TestActivityEnvironment::class.java) + .apply { isAccessible = true } + .newInstance(javaEnv) + + getStore(context).put(TEST_ENVIRONMENT_KEY, kEnv) + } + + override fun afterEach(context: ExtensionContext) { + getStore(context).get(TEST_ENVIRONMENT_KEY, KTestActivityEnvironment::class.java)?.close() + } + + private fun getStore(context: ExtensionContext): ExtensionContext.Store { + val namespace = ExtensionContext.Namespace.create( + KTestActivityExtension::class.java, + context.requiredTestMethod + ) + return context.getStore(namespace) + } + + public companion object { + private const val TEST_ENVIRONMENT_KEY = "testEnvironment" + + public fun newBuilder(): Builder = Builder() + } + + @TemporalDsl + public class Builder internal constructor() { + private var testEnvironmentOptions: TestEnvironmentOptions = + TestEnvironmentOptions.getDefaultInstance() + private var activityImplementations: Array = emptyArray() + private var suspendActivityImplementations: Array = emptyArray() + + /** + * Configure test environment options. + */ + public fun testEnvironmentOptions(block: KTestEnvironmentOptionsBuilder.() -> Unit) { + testEnvironmentOptions = KTestEnvironmentOptionsBuilder().apply(block).build() + } + + /** + * Set activity implementations. + */ + public fun setActivityImplementations(vararg activities: Any) { + activityImplementations = arrayOf(*activities) + } + + /** + * Set suspend activity implementations. + */ + public fun setSuspendActivityImplementations(vararg activities: Any) { + suspendActivityImplementations = arrayOf(*activities) + } + + public fun build(): KTestActivityExtension { + return KTestActivityExtension( + ExtensionConfig( + testEnvironmentOptions = testEnvironmentOptions, + activityImplementations = activityImplementations, + suspendActivityImplementations = suspendActivityImplementations + ) + ) + } + } +} + +/** + * DSL function to create a KTestActivityExtension. + */ +public fun kTestActivityExtension( + block: KTestActivityExtension.Builder.() -> Unit +): KTestActivityExtension { + return KTestActivityExtension.newBuilder().apply(block).build() +} +``` + +--- + +## 8. Implementation Notes + +### Dependencies + +The test framework module will need the following dependencies: + +```kotlin +// build.gradle.kts +dependencies { + api(project(":temporal-kotlin")) + api("io.temporal:temporal-testing:VERSION") + + implementation("org.junit.jupiter:junit-jupiter-api:5.10.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") + + testImplementation("org.junit.jupiter:junit-jupiter:5.10.0") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") +} +``` + +### Module Structure + +``` +temporal-kotlin-testing/ +├── src/main/kotlin/io/temporal/kotlin/testing/ +│ ├── KTestWorkflowEnvironment.kt +│ ├── KTestWorkflowExtension.kt +│ ├── KTestActivityEnvironment.kt +│ ├── KTestActivityExtension.kt +│ ├── KTestEnvironmentOptions.kt +│ ├── KWorker.kt +│ └── WorkflowInitialTime.kt +└── src/test/kotlin/io/temporal/kotlin/testing/ + ├── KTestWorkflowEnvironmentTest.kt + ├── KTestWorkflowExtensionTest.kt + ├── KTestActivityEnvironmentTest.kt + └── KTestActivityExtensionTest.kt +``` + +### Key Integration Points + +1. **SuspendActivityWrapper**: Reuse existing implementation for suspend activity support +2. **KotlinPlugin**: Ensure Kotlin workflow support via existing plugin +3. **KWorkflowClient**: Return Kotlin client from test environment +4. **KWorkflowOptions**: Use existing options classes + +### Thread Safety + +- Test environments are designed for single-threaded test execution +- Each test method gets its own environment instance (via JUnit extension lifecycle) +- Suspend functions use `Dispatchers.IO` for blocking operations + +--- + +## 9. Summary + +This implementation design provides: + +| Component | Purpose | +|-----------|---------| +| `KTestEnvironmentOptions` | Configuration with DSL builder | +| `KWorker` | Kotlin wrapper for Worker with suspend activity support | +| `KTestWorkflowEnvironment` | Main test environment with time skipping | +| `KTestWorkflowExtension` | JUnit 5 extension for workflow tests | +| `KTestActivityEnvironment` | Activity unit testing environment | +| `KTestActivityExtension` | JUnit 5 extension for activity tests | +| `WorkflowInitialTime` | Annotation for per-test initial time | + +The design: +- Wraps Java SDK test framework for full compatibility +- Provides Kotlin-idiomatic DSL builders +- Supports suspend functions throughout +- Integrates with existing Kotlin SDK components +- Follows established Kotlin SDK patterns diff --git a/kotlin/implementation/test-framework-implementation-plan.md b/kotlin/implementation/test-framework-implementation-plan.md new file mode 100644 index 0000000..3173177 --- /dev/null +++ b/kotlin/implementation/test-framework-implementation-plan.md @@ -0,0 +1,835 @@ +# Kotlin SDK Test Framework Implementation Plan + +## Overview + +This document outlines the implementation plan for the Kotlin SDK test framework, broken down into small, incremental commits. Each commit is designed to: + +- Be as small as possible while adding testable functionality +- Make sense in isolation +- Build on previous commits without breaking them +- Have clear test scenarios + +**Total commits: 26** + +--- + +## Phase 1: Foundation (Commits 1-3) + +### Commit 1: Add temporal-kotlin-testing module structure + +**Description:** Set up the new Gradle module with dependencies. + +**Files:** +``` +temporal-kotlin-testing/ +├── build.gradle.kts +└── src/ + ├── main/kotlin/io/temporal/kotlin/testing/ + │ └── .gitkeep + └── test/kotlin/io/temporal/kotlin/testing/ + └── .gitkeep +``` + +**Changes:** +- Create `temporal-kotlin-testing` directory structure +- Add `build.gradle.kts` with dependencies: + - `api(project(":temporal-kotlin"))` + - `api("io.temporal:temporal-testing")` + - `implementation("org.junit.jupiter:junit-jupiter-api")` + - `implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core")` +- Add module to root `settings.gradle.kts` + +**Test scenarios:** +- Module compiles successfully +- Dependencies resolve correctly + +--- + +### Commit 2: Add WorkflowInitialTime annotation + +**Description:** Add annotation for per-test initial time override. + +**Files:** +``` +temporal-kotlin-testing/src/main/kotlin/io/temporal/kotlin/testing/ +└── WorkflowInitialTime.kt +``` + +**Content:** +```kotlin +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class WorkflowInitialTime(val value: String) +``` + +**Test scenarios:** +- Annotation can be applied to functions +- Value is accessible via reflection +- Invalid ISO-8601 strings detected at parse time (not annotation time) + +--- + +### Commit 3: Add KTestEnvironmentOptions with DSL builder + +**Description:** Add configuration options with Kotlin DSL support. + +**Files:** +``` +temporal-kotlin-testing/src/main/kotlin/io/temporal/kotlin/testing/ +└── KTestEnvironmentOptions.kt +``` + +**Content:** +- `KTestEnvironmentOptionsBuilder` class with: + - `namespace: String` (default: "UnitTest") + - `initialTime: Instant?` + - `useTimeskipping: Boolean` (default: true) + - `useExternalService: Boolean` (default: false) + - `target: String?` + - `metricsScope: Scope?` + - `workerFactoryOptions {}` DSL + - `workflowClientOptions {}` DSL + - `workflowServiceStubsOptions {}` DSL + - `searchAttributes {}` nested DSL with `register(name, type)` + - `build()` method returning `TestEnvironmentOptions` +- `KTestEnvironmentOptions` class with: + - `internal val javaOptions: TestEnvironmentOptions` + - Companion: `newBuilder {}`, `getDefaultInstance()` + +**Test scenarios:** +- Builder with defaults creates valid TestEnvironmentOptions +- Each property correctly maps to Java options +- Nested DSLs (workerFactoryOptions, searchAttributes) work correctly +- `@TemporalDsl` annotation prevents DSL leakage + +--- + +## Phase 2: KWorker (Commits 4-6) ✅ COMPLETE + +> **Note:** KWorker was initially implemented in `temporal-kotlin-testing` but has since been moved to `temporal-kotlin/src/main/kotlin/io/temporal/kotlin/worker/KWorker.kt` to make it available for production use. `KWorkerFactory.newWorker()` now returns `KWorker`. + +### Commit 4: Add KWorker with workflow registration ✅ + +**Description:** Add Kotlin worker wrapper with workflow registration methods. + +**Files:** +``` +temporal-kotlin/src/main/kotlin/io/temporal/kotlin/worker/ +└── KWorker.kt +``` + +**Content:** +```kotlin +class KWorker internal constructor(val worker: Worker) { + inline fun registerWorkflowImplementationTypes() + fun registerWorkflowImplementationTypes(vararg workflowClasses: KClass<*>) + fun registerWorkflowImplementationTypes( + options: WorkflowImplementationOptions, + vararg workflowClasses: KClass<*> + ) + inline fun registerWorkflowImplementationTypes( + options: WorkflowImplementationOptions.Builder.() -> Unit + ) +} +``` + +**Test scenarios:** +- Reified generic registration works: `registerWorkflowImplementationTypes()` +- KClass vararg registration works +- Options DSL correctly applies WorkflowImplementationOptions +- Underlying Worker receives correct registrations + +--- + +### Commit 5: Add KWorker activity registration methods ✅ + +**Description:** Add activity registration including suspend activity support. + +**Files:** +- `KWorker.kt` (modify) + +**Content:** +```kotlin +// Add to KWorker class: +fun registerActivitiesImplementations(vararg activities: Any) +fun registerSuspendActivities(vararg activities: Any) +``` + +**Test scenarios:** +- Regular activities registered and callable +- Suspend activities wrapped via `SuspendActivityWrapper` +- Mixed registration (regular + suspend) works + +--- + +### Commit 6: Add KWorker Nexus service registration ✅ + +**Description:** Add Nexus service registration method. + +**Files:** +- `KWorker.kt` (modify) + +**Content:** +```kotlin +// Add to KWorker class: +fun registerNexusServiceImplementations(vararg services: Any) +``` + +**Test scenarios:** +- Nexus services registered on underlying Worker +- Services callable via Nexus operations + +--- + +## Phase 3: KTestWorkflowEnvironment (Commits 7-12) + +### Commit 7: Add KTestWorkflowEnvironment basic structure + +**Description:** Add core test environment with worker creation. + +**Files:** +``` +temporal-kotlin-testing/src/main/kotlin/io/temporal/kotlin/testing/ +└── KTestWorkflowEnvironment.kt +``` + +**Content:** +```kotlin +class KTestWorkflowEnvironment private constructor( + private val testEnvironment: TestWorkflowEnvironment +) : Closeable { + val namespace: String + + fun newWorker(taskQueue: String): KWorker + fun newWorker(taskQueue: String, options: WorkerOptions.Builder.() -> Unit): KWorker + fun newWorker(taskQueue: String, options: WorkerOptions): KWorker + + companion object { + fun newInstance(): KTestWorkflowEnvironment + fun newInstance(options: KTestEnvironmentOptionsBuilder.() -> Unit): KTestWorkflowEnvironment + fun newInstance(options: KTestEnvironmentOptions): KTestWorkflowEnvironment + } +} +``` + +**Test scenarios:** +- Environment created with defaults +- Environment created with DSL options +- Workers created with correct task queue +- Workers return KWorker instances + +--- + +### Commit 8: Add KTestWorkflowEnvironment client access + +**Description:** Add workflow client and service stub access. + +**Files:** +- `KTestWorkflowEnvironment.kt` (modify) + +**Content:** +```kotlin +// Add to KTestWorkflowEnvironment: +val workflowClient: KWorkflowClient // lazy +val workflowServiceStubs: WorkflowServiceStubs +val operatorServiceStubs: OperatorServiceStubs +``` + +**Test scenarios:** +- `workflowClient` returns valid KWorkflowClient +- Client can create workflow stubs +- Service stubs accessible for advanced operations + +--- + +### Commit 9: Add KTestWorkflowEnvironment time manipulation + +**Description:** Add time-related properties and sleep function. + +**Files:** +- `KTestWorkflowEnvironment.kt` (modify) + +**Content:** +```kotlin +// Add to KTestWorkflowEnvironment: +val currentTimeMillis: Long +val currentTime: Instant + +suspend fun sleep(duration: kotlin.time.Duration) +suspend fun sleep(duration: java.time.Duration) +``` + +**Test scenarios:** +- `currentTime` returns test environment time (not system time) +- `sleep()` advances test time without real delay +- Both Kotlin and Java Duration overloads work +- Workflow timers fire after sleep advances time + +--- + +### Commit 10: Add KTestWorkflowEnvironment delayed callbacks + +**Description:** Add delayed callback registration. + +**Files:** +- `KTestWorkflowEnvironment.kt` (modify) + +**Content:** +```kotlin +// Add to KTestWorkflowEnvironment: +fun registerDelayedCallback(delay: kotlin.time.Duration, callback: () -> Unit) +fun registerDelayedCallback(delay: java.time.Duration, callback: () -> Unit) +``` + +**Test scenarios:** +- Callback executes when test time reaches delay +- Multiple callbacks execute in correct order +- Both Duration types work + +--- + +### Commit 11: Add KTestWorkflowEnvironment lifecycle management + +**Description:** Add start, shutdown, and termination methods. + +**Files:** +- `KTestWorkflowEnvironment.kt` (modify) + +**Content:** +```kotlin +// Add to KTestWorkflowEnvironment: +val isStarted: Boolean +val isShutdown: Boolean +val isTerminated: Boolean + +fun start() +fun shutdown() +fun shutdownNow() +suspend fun awaitTermination(timeout: kotlin.time.Duration) +suspend fun awaitTermination(timeout: java.time.Duration) +override fun close() + +// Extension function: +inline fun KTestWorkflowEnvironment.use(block: (KTestWorkflowEnvironment) -> T): T +``` + +**Test scenarios:** +- `start()` enables workflow execution +- State properties reflect correct lifecycle state +- `shutdown()` stops accepting new work +- `shutdownNow()` interrupts in-progress work +- `awaitTermination()` suspends until terminated +- `close()` combines shutdownNow + awaitTermination +- `use {}` auto-closes on block exit + +--- + +### Commit 12: Add KTestWorkflowEnvironment advanced features + +**Description:** Add search attributes, Nexus endpoints, and diagnostics. + +**Files:** +- `KTestWorkflowEnvironment.kt` (modify) + +**Content:** +```kotlin +// Add to KTestWorkflowEnvironment: +fun registerSearchAttribute(name: String, type: IndexedValueType): Boolean +fun createNexusEndpoint(name: String, taskQueue: String): Endpoint +fun deleteNexusEndpoint(endpoint: Endpoint) +fun getDiagnostics(): String +``` + +**Test scenarios:** +- Search attributes registered and queryable +- Nexus endpoints created and usable +- Nexus endpoints deleted successfully +- Diagnostics returns workflow histories + +--- + +## Phase 4: KTestWorkflowExtension (Commits 13-18) + +### Commit 13: Add KTestWorkflowExtension builder basics + +**Description:** Add extension builder with workflow/activity registration. + +**Files:** +``` +temporal-kotlin-testing/src/main/kotlin/io/temporal/kotlin/testing/ +└── KTestWorkflowExtension.kt +``` + +**Content:** +```kotlin +class KTestWorkflowExtension private constructor(config: ExtensionConfig) { + private data class ExtensionConfig(...) + + class Builder { + var namespace: String + var initialTime: Instant? + var useTimeskipping: Boolean + var doNotStart: Boolean + + inline fun registerWorkflowImplementationTypes() + inline fun registerWorkflowImplementationTypes( + options: WorkflowImplementationOptions.Builder.() -> Unit + ) + fun registerWorkflowImplementationTypes(vararg classes: Class<*>) + fun setActivityImplementations(vararg activities: Any) + fun setSuspendActivityImplementations(vararg activities: Any) + fun setNexusServiceImplementations(vararg services: Any) + fun build(): KTestWorkflowExtension + } + + companion object { + fun newBuilder(): Builder + } +} + +fun kTestWorkflowExtension(block: KTestWorkflowExtension.Builder.() -> Unit): KTestWorkflowExtension +``` + +**Test scenarios:** +- Builder creates extension with correct config +- DSL function `kTestWorkflowExtension {}` works +- Workflow types stored correctly +- Activity implementations stored correctly + +--- + +### Commit 14: Add KTestWorkflowExtension service configuration + +**Description:** Add worker, client, and service configuration DSLs. + +**Files:** +- `KTestWorkflowExtension.kt` (modify) + +**Content:** +```kotlin +// Add to Builder: +fun workerOptions(block: WorkerOptions.Builder.() -> Unit) +fun workerFactoryOptions(block: WorkerFactoryOptions.Builder.() -> Unit) +fun workflowClientOptions(block: WorkflowClientOptions.Builder.() -> Unit) +fun useInternalService() +fun useExternalService() +fun useExternalService(address: String) +fun searchAttributes(block: SearchAttributesBuilder.() -> Unit) +``` + +**Test scenarios:** +- Worker options flow through to worker creation +- Factory options configure WorkerFactory +- Client options configure WorkflowClient +- `useExternalService()` connects to real Temporal +- Search attributes registered on test server + +--- + +### Commit 15: Add KTestWorkflowExtension lifecycle callbacks + +**Description:** Implement JUnit 5 BeforeEach/AfterEach callbacks. + +**Files:** +- `KTestWorkflowExtension.kt` (modify) + +**Content:** +```kotlin +// Implement interfaces: +class KTestWorkflowExtension : BeforeEachCallback, AfterEachCallback { + override fun beforeEach(context: ExtensionContext) { + // Create test environment + // Create worker with unique task queue + // Register workflows and activities + // Start if not doNotStart + // Store in ExtensionContext.Store + } + + override fun afterEach(context: ExtensionContext) { + // Close test environment + } +} +``` + +**Test scenarios:** +- Each test gets isolated environment +- Workers registered before test runs +- Environment closed after test completes +- Test isolation verified (no cross-test contamination) + +--- + +### Commit 16: Add KTestWorkflowExtension basic parameter injection + +**Description:** Implement ParameterResolver for core types. + +**Files:** +- `KTestWorkflowExtension.kt` (modify) + +**Content:** +```kotlin +// Implement ParameterResolver: +class KTestWorkflowExtension : ParameterResolver { + override fun supportsParameter(parameterContext, extensionContext): Boolean { + // Support: KTestWorkflowEnvironment, KWorkflowClient, + // KWorkflowOptions, KWorker, Worker + } + + override fun resolveParameter(parameterContext, extensionContext): Any { + // Return appropriate instance from store + } +} +``` + +**Test scenarios:** +- `KTestWorkflowEnvironment` injected correctly +- `KWorkflowClient` injected correctly +- `KWorkflowOptions` injected with correct task queue +- `KWorker` injected correctly +- `Worker` (Java) injected correctly + +--- + +### Commit 17: Add KTestWorkflowExtension workflow stub injection + +**Description:** Add workflow interface stub injection. + +**Files:** +- `KTestWorkflowExtension.kt` (modify) + +**Content:** +```kotlin +// Enhance parameter resolution: +private val supportedParameterTypes: MutableSet> + +// In constructor: populate from registered workflow types +// In supportsParameter: check workflow interfaces +// In resolveParameter: create workflow stub +``` + +**Test scenarios:** +- Workflow interface parameters injected as stubs +- Stubs have correct task queue +- Stubs can execute workflows +- Dynamic workflow support works + +--- + +### Commit 18: Add KTestWorkflowExtension annotations and diagnostics + +**Description:** Add WorkflowInitialTime support and test failure diagnostics. + +**Files:** +- `KTestWorkflowExtension.kt` (modify) + +**Content:** +```kotlin +// Implement TestWatcher: +class KTestWorkflowExtension : TestWatcher { + override fun testFailed(context: ExtensionContext, cause: Throwable) { + // Print diagnostics to stderr + } +} + +// In beforeEach: +// Check for @WorkflowInitialTime annotation +// Override initial time if present +``` + +**Test scenarios:** +- `@WorkflowInitialTime` overrides initial time for specific test +- Test failure prints workflow histories to stderr +- Diagnostics help debug failing tests + +--- + +## Phase 5: KTestActivityEnvironment (Commits 19-24) + +### Commit 19: Add KTestActivityEnvironment basic structure + +**Description:** Add core activity test environment with registration. + +**Files:** +``` +temporal-kotlin-testing/src/main/kotlin/io/temporal/kotlin/testing/ +└── KTestActivityEnvironment.kt +``` + +**Content:** +```kotlin +class KTestActivityEnvironment private constructor( + private val testEnvironment: TestActivityEnvironment +) : Closeable { + fun registerActivitiesImplementations(vararg activities: Any) + fun registerSuspendActivities(vararg activities: Any) + override fun close() + + companion object { + fun newInstance(): KTestActivityEnvironment + fun newInstance(options: KTestEnvironmentOptionsBuilder.() -> Unit): KTestActivityEnvironment + fun newInstance(options: KTestEnvironmentOptions): KTestActivityEnvironment + } +} +``` + +**Test scenarios:** +- Environment created with defaults +- Activities registered successfully +- Suspend activities wrapped and registered +- Environment closes cleanly + +--- + +### Commit 20: Add KTestActivityEnvironment executeActivity (0-2 args) + +**Description:** Add activity execution via method references. + +**Files:** +- `KTestActivityEnvironment.kt` (modify) + +**Content:** +```kotlin +// Add to KTestActivityEnvironment: +private fun createActivityStub(activity: KFunction<*>, options: ActivityOptions): T + +fun executeActivity(activity: KFunction1, options: KActivityOptions): R +fun executeActivity(activity: KFunction2, options: KActivityOptions, arg1: A1): R +fun executeActivity(activity: KFunction3, options: KActivityOptions, arg1: A1, arg2: A2): R +``` + +**Test scenarios:** +- Activity with no args executes correctly +- Activity with 1 arg executes correctly +- Activity with 2 args executes correctly +- Method reference extracts correct interface + +--- + +### Commit 21: Add KTestActivityEnvironment executeActivity (3+ args) + +**Description:** Add activity execution for higher arities. + +**Files:** +- `KTestActivityEnvironment.kt` (modify) + +**Content:** +```kotlin +// Add to KTestActivityEnvironment: +fun executeActivity( + activity: KFunction4, + options: KActivityOptions, + arg1: A1, arg2: A2, arg3: A3 +): R + +// Additional arities as needed (4, 5, 6 args) +``` + +**Test scenarios:** +- Activity with 3 args executes correctly +- Activity with 4+ args executes correctly + +--- + +### Commit 22: Add KTestActivityEnvironment suspend activity support + +**Description:** Add suspend function overloads for activity execution. + +**Files:** +- `KTestActivityEnvironment.kt` (modify) + +**Content:** +```kotlin +// Add suspend overloads: +@JvmName("executeSuspendActivity0") +suspend fun executeActivity(activity: KSuspendFunction1, options: KActivityOptions): R + +@JvmName("executeSuspendActivity1") +suspend fun executeActivity(activity: KSuspendFunction2, options: KActivityOptions, arg1: A1): R + +// Continue for 2, 3+ args +``` + +**Test scenarios:** +- Suspend activity with no args executes +- Suspend activity with args executes +- Coroutine context preserved correctly + +--- + +### Commit 23: Add KTestActivityEnvironment local activity support + +**Description:** Add local activity execution methods. + +**Files:** +- `KTestActivityEnvironment.kt` (modify) + +**Content:** +```kotlin +// Add to KTestActivityEnvironment: +private fun createLocalActivityStub(activity: KFunction<*>, options: LocalActivityOptions): T + +fun executeLocalActivity(activity: KFunction1, options: KLocalActivityOptions): R +fun executeLocalActivity(activity: KFunction2, options: KLocalActivityOptions, arg1: A1): R +fun executeLocalActivity(activity: KFunction3, options: KLocalActivityOptions, arg1: A1, arg2: A2): R +fun executeLocalActivity(activity: KFunction4, options: KLocalActivityOptions, arg1: A1, arg2: A2, arg3: A3): R +``` + +**Test scenarios:** +- Local activity executes with correct options +- Local activity timeout behavior works +- Local activity retry behavior works + +--- + +### Commit 24: Add KTestActivityEnvironment heartbeat and cancellation + +**Description:** Add heartbeat testing and cancellation support. + +**Files:** +- `KTestActivityEnvironment.kt` (modify) + +**Content:** +```kotlin +// Add to KTestActivityEnvironment: +fun setHeartbeatDetails(details: T) + +inline fun setActivityHeartbeatListener(noinline listener: (T) -> Unit) +fun setActivityHeartbeatListener(detailsClass: Class, detailsType: Type, listener: (T) -> Unit) + +fun requestCancelActivity() +``` + +**Test scenarios:** +- `setHeartbeatDetails` provides checkpoint to activity +- Heartbeat listener receives heartbeat calls +- `requestCancelActivity` triggers cancellation on next heartbeat +- `ActivityCanceledException` thrown after cancellation + +--- + +## Phase 6: KTestActivityExtension (Commits 25-26) + +### Commit 25: Add KTestActivityExtension builder + +**Description:** Add extension builder with DSL. + +**Files:** +``` +temporal-kotlin-testing/src/main/kotlin/io/temporal/kotlin/testing/ +└── KTestActivityExtension.kt +``` + +**Content:** +```kotlin +class KTestActivityExtension private constructor(config: ExtensionConfig) { + private data class ExtensionConfig( + val testEnvironmentOptions: TestEnvironmentOptions, + val activityImplementations: Array, + val suspendActivityImplementations: Array + ) + + class Builder { + fun testEnvironmentOptions(block: KTestEnvironmentOptionsBuilder.() -> Unit) + fun setActivityImplementations(vararg activities: Any) + fun setSuspendActivityImplementations(vararg activities: Any) + fun build(): KTestActivityExtension + } + + companion object { + fun newBuilder(): Builder + } +} + +fun kTestActivityExtension(block: KTestActivityExtension.Builder.() -> Unit): KTestActivityExtension +``` + +**Test scenarios:** +- Builder creates extension with correct config +- DSL function works +- Activity implementations stored correctly + +--- + +### Commit 26: Add KTestActivityExtension lifecycle and injection + +**Description:** Implement JUnit 5 callbacks and parameter injection. + +**Files:** +- `KTestActivityExtension.kt` (modify) + +**Content:** +```kotlin +class KTestActivityExtension : ParameterResolver, BeforeEachCallback, AfterEachCallback { + override fun supportsParameter(parameterContext, extensionContext): Boolean { + // Support KTestActivityEnvironment + } + + override fun resolveParameter(parameterContext, extensionContext): Any { + // Return KTestActivityEnvironment from store + } + + override fun beforeEach(context: ExtensionContext) { + // Create environment + // Register activities + // Store in context + } + + override fun afterEach(context: ExtensionContext) { + // Close environment + } +} +``` + +**Test scenarios:** +- `KTestActivityEnvironment` injected into test methods +- Each test gets isolated environment +- Activities callable via injected environment +- Environment closed after test + +--- + +## Summary + +| Phase | Commits | Description | +|-------|---------|-------------| +| 1. Foundation | 1-3 | Module setup, annotation, options | +| 2. KWorker | 4-6 | Worker wrapper with registrations | +| 3. KTestWorkflowEnvironment | 7-12 | Main test environment | +| 4. KTestWorkflowExtension | 13-18 | JUnit 5 workflow extension | +| 5. KTestActivityEnvironment | 19-24 | Activity test environment | +| 6. KTestActivityExtension | 25-26 | JUnit 5 activity extension | + +**Total: 26 commits** + +## Dependencies Graph + +``` +Commit 1 (module) + └── Commit 2 (annotation) + └── Commit 3 (options) + └── Commits 4-6 (KWorker) + └── Commits 7-12 (KTestWorkflowEnvironment) + └── Commits 13-18 (KTestWorkflowExtension) + └── Commits 19-24 (KTestActivityEnvironment) + └── Commits 25-26 (KTestActivityExtension) +``` + +## Testing Strategy + +Each commit should include: + +1. **Unit tests** for the new functionality +2. **Integration tests** where applicable (especially for environment/extension commits) +3. **Example usage** in test code demonstrating the API + +Test files follow the pattern: +``` +temporal-kotlin-testing/src/test/kotlin/io/temporal/kotlin/testing/ +├── WorkflowInitialTimeTest.kt +├── KTestEnvironmentOptionsTest.kt +├── KWorkerTest.kt +├── KTestWorkflowEnvironmentTest.kt +├── KTestWorkflowExtensionTest.kt +├── KTestActivityEnvironmentTest.kt +└── KTestActivityExtensionTest.kt +``` diff --git a/kotlin/kotlin-idioms.md b/kotlin/kotlin-idioms.md new file mode 100644 index 0000000..dc02c38 --- /dev/null +++ b/kotlin/kotlin-idioms.md @@ -0,0 +1,208 @@ +# Kotlin Idioms + +The Kotlin SDK uses **standard Kotlin patterns** wherever possible instead of custom APIs. + +| Java SDK | Kotlin SDK | +|----------|------------| +| `void method()` | `suspend fun method()` | +| `Async.function(() -> ...)` | `async { ... }` | +| `Promise.allOf(...).get()` | `awaitAll(d1, d2)` | +| `Workflow.sleep(duration)` | `delay(duration)` | +| `Workflow.newDetachedCancellationScope()` | `withContext(NonCancellable)` | +| `Duration.ofSeconds(30)` | `30.seconds` | +| `Optional` | `T?` | + +## Suspend Functions + +Workflows and activities use `suspend fun` for natural coroutine integration: + +```kotlin +// Method annotations are optional - use only when customizing names +@WorkflowInterface +interface OrderWorkflow { + suspend fun processOrder(order: Order): OrderResult + + suspend fun updatePriority(priority: Priority) // Signal handler + + suspend fun addItem(item: OrderItem): Boolean // Update handler + + val status: OrderStatus // Query handler - queries are NOT suspend (synchronous) +} + +// Use annotations only when customizing names +@WorkflowInterface +interface CustomNameWorkflow { + @WorkflowMethod(name = "ProcessOrder") + suspend fun processOrder(order: Order): OrderResult + + @SignalMethod(name = "update-priority") + suspend fun updatePriority(priority: Priority) + + @UpdateMethod(name = "add-item") + suspend fun addItem(item: OrderItem): Boolean + + @QueryMethod(name = "get-status") + val status: OrderStatus +} + +@ActivityInterface +interface OrderActivities { + suspend fun validateOrder(order: Order): Boolean + + suspend fun chargePayment(order: Order): PaymentResult +} +``` + +> **Note:** Query methods are never `suspend` because queries must return immediately without blocking. + +## Coroutines and Concurrency + +Use standard `kotlinx.coroutines` patterns for parallel execution: + +```kotlin +override suspend fun processOrder(order: Order): OrderResult = coroutineScope { + // Parallel execution using standard async + val validation = async { KWorkflow.executeActivity(OrderActivities::validateOrder, options, order) } + val inventory = async { KWorkflow.executeActivity(OrderActivities::checkInventory, options, order) } + + // Wait for all - standard awaitAll + val (isValid, hasInventory) = awaitAll(validation, inventory) + + if (!isValid || !hasInventory) { + return@coroutineScope OrderResult(success = false) + } + + // Sequential execution + val charged = KWorkflow.executeActivity(OrderActivities::chargePayment, options, order) + val shipped = KWorkflow.executeActivity(OrderActivities::shipOrder, options, order) + + OrderResult(success = true, trackingNumber = shipped) +} +``` + +| Kotlin Pattern | Purpose | +|----------------|---------| +| `coroutineScope { }` | Structured concurrency - if one fails, all cancel | +| `async { }` | Start parallel operation, returns `Deferred` | +| `awaitAll(d1, d2)` | Wait for multiple deferreds | +| `delay(duration)` | Temporal timer (deterministic) | + +## Cancellation + +Use standard Kotlin cancellation patterns: + +```kotlin +override suspend fun processOrder(order: Order): OrderResult { + return try { + doProcessOrder(order) + } catch (e: CancellationException) { + // Workflow was cancelled - run cleanup in non-cancellable context + withContext(NonCancellable) { + KWorkflow.executeActivity(OrderActivities::releaseInventory, options, order) + KWorkflow.executeActivity(OrderActivities::refundPayment, options, order) + } + throw e // Re-throw to propagate cancellation + } +} +``` + +| Kotlin Pattern | Java SDK Equivalent | +|----------------|---------------------| +| `catch (e: CancellationException)` | `CancellationScope` failure callback | +| `withContext(NonCancellable) { }` | `Workflow.newDetachedCancellationScope()` | +| `coroutineScope { }` | `Workflow.newCancellationScope()` | +| `isActive` / `ensureActive()` | `CancellationScope.isCancelRequested()` | + +## Timeouts + +Use `withTimeout` for deadline-based cancellation: + +```kotlin +override suspend fun processWithDeadline(order: Order): OrderResult { + return withTimeout(1.hours) { + // Everything here cancels if it takes > 1 hour + KWorkflow.executeActivity(OrderActivities::validateOrder, options, order) + KWorkflow.executeActivity(OrderActivities::chargePayment, options, order) + OrderResult(success = true) + } +} + +// Or get null instead of exception +val result = withTimeoutOrNull(30.minutes) { + KWorkflow.executeActivity(OrderActivities::slowOperation, options, data) +} +``` + +## Kotlin Duration + +Use `kotlin.time.Duration` for readable time expressions: + +```kotlin +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.hours + +val options = KActivityOptions( + startToCloseTimeout = 30.seconds, + scheduleToCloseTimeout = 5.minutes, + heartbeatTimeout = 10.seconds +) + +delay(1.hours) +withTimeout(30.minutes) { ... } +``` + +## Null Safety + +Nullable types replace `Optional`: + +```kotlin +// KWorkflowInfo uses nullable instead of Optional +val info = KWorkflow.info +val parentId: String? = info.parentWorkflowId // null if no parent + +// Activity heartbeat details +val progress = KActivity.executionContext.heartbeatDetails() +val startIndex = progress ?: 0 // Elvis operator for default +``` + +This eliminates `.orElse(null)`, `.isPresent`, and other Optional ceremony. + +## Data Classes + +Use Kotlin data classes with `@Serializable` for workflow inputs/outputs: + +```kotlin +@Serializable +data class Order( + val id: String, + val customerId: String, + val items: List, + val priority: Priority = Priority.NORMAL +) + +@Serializable +data class OrderResult( + val success: Boolean, + val trackingNumber: String? = null, + val errorMessage: String? = null +) + +@Serializable +enum class Priority { LOW, NORMAL, HIGH } +``` + +Data classes provide: +- Automatic `equals()`, `hashCode()`, `toString()` +- `copy()` for creating modified instances +- Destructuring: `val (id, customerId) = order` + +## Related + +- [KOptions Classes](./configuration/koptions.md) - Kotlin-native configuration +- [Workflow Definition](./workflows/definition.md) - Using these idioms in workflows +- [Activity Definition](./activities/definition.md) - Using these idioms in activities + +--- + +**Next:** [Configuration](./configuration/README.md) diff --git a/kotlin/migration.md b/kotlin/migration.md new file mode 100644 index 0000000..df7c61e --- /dev/null +++ b/kotlin/migration.md @@ -0,0 +1,175 @@ +# Migration from Java SDK + +## API Mapping + +| Java SDK | Kotlin SDK | +|----------|------------| +| **Client** | | +| `WorkflowClient.newInstance(service)` | `KClient.connect(options)` | +| `client.newWorkflowStub(Cls, opts)` | `client.startWorkflow(Interface::method, options, ...)` | +| `client.newWorkflowStub(Cls, id)` | `client.workflowHandle(id)` | +| `stub.method(arg)` | `client.executeWorkflow(Interface::method, options, arg)` | +| `stub.signal(arg)` | `handle.signal(T::method, arg)` | +| `stub.query()` | `handle.query(T::method)` | +| `handle.getResult()` | `handle.result()` or `handle.result()` | +| **Worker** | | +| `WorkerFactory.newInstance(client)` | `KWorkerFactory(client)` | +| `factory.newWorker(taskQueue)` | `factory.newWorker(taskQueue)` → `KWorker` | +| `worker.registerWorkflowImplementationTypes(Cls)` | `worker.registerWorkflowImplementationTypes(Cls::class)` | +| `worker.registerActivitiesImplementations(impl)` | `worker.registerActivitiesImplementations(impl)` | +| **KWorkflow Object** | | +| `Workflow.getInfo()` | `KWorkflow.info` | +| `Workflow.getLogger()` | Standard SLF4J logger (MDC populated by SDK) | +| `Workflow.sleep(duration)` | `delay(duration)` or `KWorkflow.delay(duration, options)` | +| `Workflow.await(() -> cond)` | `KWorkflow.awaitCondition { cond }` | +| `Workflow.sideEffect(cls, func)` | `KWorkflow.sideEffect { func }` | +| `Workflow.getVersion(id, min, max)` | `KWorkflow.version(id, min, max)` | +| `Workflow.continueAsNew(args)` | `KWorkflow.continueAsNew(args)` | +| `Workflow.randomUUID()` | `KWorkflow.randomUUID()` | +| `Workflow.newRandom()` | `KWorkflow.newRandom()` | +| `Workflow.currentTimeMillis()` | `KWorkflow.currentTimeMillis()` | +| `Workflow.getTypedSearchAttributes()` | `KWorkflow.searchAttributes` | +| `Workflow.upsertTypedSearchAttributes(...)` | `KWorkflow.upsertSearchAttributes(...)` | +| `Workflow.getMemo(key, cls)` | `KWorkflow.memo(key)` | +| `Workflow.upsertMemo(map)` | `KWorkflow.upsertMemo(map)` | +| `Workflow.getMetricsScope()` | `KWorkflow.metricsScope` | +| `Workflow.isReplaying()` | `KWorkflow.isReplaying` | +| `Workflow.getCurrentUpdateInfo()` | `KWorkflow.currentUpdateInfo` | +| **Activities (from workflow)** | | +| `Workflow.newActivityStub(Cls, opts)` | *(not needed - options passed per call)* | +| `stub.method(arg)` | `KWorkflow.executeActivity(Interface::method, options, arg)` | +| `Workflow.newLocalActivityStub(Cls, opts)` | *(not needed - options passed per call)* | +| `localStub.method(arg)` | `KWorkflow.executeLocalActivity(Interface::method, options, arg)` | +| `Async.function(stub::method, arg)` | `coroutineScope { async { KWorkflow.executeActivity(...) } }` | +| **Child Workflows** | | +| `Workflow.newChildWorkflowStub(Cls, opts)` | *(not needed - options passed per call)* | +| `childStub.method(arg)` | `KWorkflow.executeChildWorkflow(Interface::method, options, arg)` | +| `Async.function(childStub::method, arg)` | `KWorkflow.startChildWorkflow(...)` → `KChildWorkflowHandle` | +| `Workflow.getWorkflowExecution(childStub)` | `childHandle.workflowId` / `childHandle.runId()` | +| **External Workflows** | | +| `Workflow.newExternalWorkflowStub(Cls, id)` | `KWorkflow.getExternalWorkflowHandle(id)` | +| `externalStub.signal(arg)` | `externalHandle.signal(T::method, arg)` | +| `Workflow.newUntypedExternalWorkflowStub(id)` | `KWorkflow.getExternalWorkflowHandle(id)` | +| **Cancellation** | | +| `Workflow.newCancellationScope(...)` | `coroutineScope { ... }` | +| `Workflow.newDetachedCancellationScope(...)` | `withContext(NonCancellable) { ... }` | +| `scope.cancel()` | `job.cancel()` | +| `CancellationScope.isCancelRequested()` | `!isActive` | +| **KActivityContext** | | +| `Activity.getExecutionContext()` | `KActivityContext.current()` | +| `context.getInfo()` | `KActivityContext.current().info` | +| `context.heartbeat(details)` | `KActivityContext.current().heartbeat(details)` | +| `context.getHeartbeatDetails(cls)` | `KActivityContext.current().lastHeartbeatDetails()` | +| `context.doNotCompleteOnReturn()` | `KActivityContext.current().doNotCompleteOnReturn()` | +| **Testing** | | +| `TestWorkflowEnvironment.newInstance()` | `KTestWorkflowEnvironment.newInstance()` | +| `testEnv.newWorker(taskQueue)` | `testEnv.newWorker(taskQueue)` → `KWorker` | +| `testEnv.getWorkflowClient()` | `testEnv.workflowClient` → `KClient` | +| `testEnv.sleep(duration)` | `testEnv.sleep(duration)` | +| **Primitives** | | +| `Promise` | `Deferred` via `async { }` | +| `Async.function(...)` + `Promise.get()` | `coroutineScope { async { ... } }` + `awaitAll()` | +| `Optional` | `T?` | +| `Duration.ofSeconds(30)` | `30.seconds` | +| **Options** | | +| `ActivityOptions.newBuilder()...build()` | `KActivityOptions(...)` | +| `LocalActivityOptions.newBuilder()...build()` | `KLocalActivityOptions(...)` | +| `ChildWorkflowOptions.newBuilder()...build()` | `KChildWorkflowOptions(...)` | +| `WorkflowOptions.newBuilder()...build()` | `KWorkflowOptions(...)` | +| `RetryOptions.newBuilder()...build()` | `KRetryOptions(...)` | +| `ContinueAsNewOptions.newBuilder()...build()` | `KContinueAsNewOptions(...)` | + +## Before (Java) + +```java +@WorkflowInterface +public interface GreetingWorkflow { + @WorkflowMethod + String getGreeting(String name); +} + +public class GreetingWorkflowImpl implements GreetingWorkflow { + @Override + public String getGreeting(String name) { + ActivityStub activities = Workflow.newUntypedActivityStub( + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(30)) + .build() + ); + return activities.execute("greet", String.class, name); + } +} +``` + +## After (Kotlin) + +```kotlin +@WorkflowInterface +interface GreetingWorkflow { + @WorkflowMethod + suspend fun getGreeting(name: String): String +} + +class GreetingWorkflowImpl : GreetingWorkflow { + override suspend fun getGreeting(name: String): String { + return KWorkflow.executeActivity( + "greet", + KActivityOptions(startToCloseTimeout = 30.seconds), + name + ) + } +} +``` + +## Interoperability + +### Kotlin Workflows Calling Java Activities + +```kotlin +override suspend fun processOrder(order: Order): String { + // JavaActivities is a Java @ActivityInterface - no stub needed + return KWorkflow.executeActivity( + JavaActivities::process, + KActivityOptions(startToCloseTimeout = 30.seconds), + order + ) +} +``` + +### Java Clients Calling Kotlin Workflows + +```kotlin +val result = client.executeWorkflow( + JavaWorkflowInterface::execute, + KWorkflowOptions(workflowId = "java-workflow", taskQueue = "java-queue"), + input +) +``` + +### Mixed Workers + +A single worker can host both Java and Kotlin workflows: + +```kotlin +// Java workflows (thread-based) +worker.registerWorkflowImplementationTypes( + OrderWorkflowJavaImpl::class.java +) + +// Kotlin workflows (coroutine-based) +worker.registerWorkflowImplementationTypes( + GreetingWorkflowImpl::class +) + +// Both run on the same worker - execution model is per-workflow-instance +factory.start() +``` + +## Related + +- [Kotlin Idioms](./kotlin-idioms.md) - Kotlin-specific patterns +- [Worker Setup](./worker/setup.md) - Mixed Java/Kotlin workers + +--- + +**Next:** [API Parity](./api-parity.md) diff --git a/kotlin/open-questions.md b/kotlin/open-questions.md new file mode 100644 index 0000000..f856f61 --- /dev/null +++ b/kotlin/open-questions.md @@ -0,0 +1,618 @@ +# Open Questions + +This document tracks API design questions that need discussion and decisions before implementation. + +--- + +## Default Parameter Values + +**Status:** Decided + +### Decision + +**Default parameter values are allowed for methods with 0 or 1 arguments** in workflow methods, activity methods, signal handlers, update handlers, and query handlers. This aligns with Python, .NET, and Ruby SDKs which support defaults. + +```kotlin +// ✓ ALLOWED - 0 arguments with defaults (effectively optional call) +suspend fun getStatus(includeDetails: Boolean = false): Status + +// ✓ ALLOWED - 1 argument with default +suspend fun processOrder(priority: Int = 0): OrderResult + +// ✗ NOT ALLOWED - 2+ arguments, any with defaults +suspend fun processOrder(orderId: String, priority: Int = 0) // Error! + +// ✓ CORRECT for 2+ arguments - use a parameter object with optional fields +data class ProcessOrderParams( + val orderId: String, + val priority: Int? = null +) + +suspend fun processOrder(params: ProcessOrderParams) +``` + +### Rationale + +1. **SDK Alignment** - Python, .NET, and Ruby SDKs allow default parameters; aligning with them provides consistency +2. **0-1 Argument Simplicity** - For simple methods, defaults are unambiguous and convenient +3. **2+ Arguments Complexity** - With multiple arguments, defaults create serialization and cross-language issues +4. **Parameter Object Pattern** - For complex inputs, parameter objects remain the recommended approach + +### Validation + +The SDK validates at registration time using Kotlin reflection: + +```kotlin +fun validateDefaultParameters(function: KFunction<*>) { + val valueParams = function.parameters + .filter { it.kind == KParameter.Kind.VALUE } + + val paramsWithDefaults = valueParams.filter { it.isOptional } + + // Allow defaults only for 0-1 argument methods + if (paramsWithDefaults.isNotEmpty() && valueParams.size > 1) { + throw IllegalArgumentException( + "Default parameter values are only allowed for methods with 0 or 1 arguments. " + + "Use a parameter object with optional fields instead." + ) + } +} +``` + +### Recommended Pattern for Complex Inputs + +Use a single parameter object with nullable/optional fields: + +```kotlin +data class OrderParams( + val orderId: String, + val priority: Int? = null, // Optional via nullability + val retryCount: Int? = null +) + +suspend fun processOrder(params: OrderParams): OrderResult +``` + +This pattern: +- Allows adding new optional fields without breaking existing callers +- Makes all parameters explicit in serialization +- Works consistently across all Temporal SDKs + +## Interfaceless Workflows and Activities + +**Status:** Decision needed + +### Problem Statement + +Currently, workflows and activities require interface definitions with annotations, which adds boilerplate: + +```kotlin +// Current approach - requires interface +@ActivityInterface +interface GreetingActivities { + suspend fun composeGreeting(greeting: String, name: String): String +} + +class GreetingActivitiesImpl : GreetingActivities { + override suspend fun composeGreeting(greeting: String, name: String) = "$greeting, $name!" +} + +@WorkflowInterface +interface GreetingWorkflow { + @WorkflowMethod + suspend fun getGreeting(name: String): String +} + +class GreetingWorkflowImpl : GreetingWorkflow { + override suspend fun getGreeting(name: String): String { + // implementation + } +} +``` + +### Proposal + +Allow defining activities and workflows directly on implementation classes without interfaces, similar to Python SDK: + +**Activities:** + +```kotlin +// Proposed approach - no interface required +class GreetingActivities { + suspend fun composeGreeting(greeting: String, name: String) = "$greeting, $name!" +} + +// In workflow - call using method reference to impl class +val result = KWorkflow.executeActivity( + GreetingActivities::composeGreeting, + KActivityOptions(startToCloseTimeout = 30.seconds), + "Hello", "World" +) +``` + +**Workflows:** + +```kotlin +// Proposed approach - no interface required +class GreetingWorkflow { + suspend fun getGreeting(name: String): String { + return KWorkflow.executeActivity( + GreetingActivities::composeGreeting, + KActivityOptions(startToCloseTimeout = 10.seconds), + "Hello", name + ) + } +} + +// Client call using method reference to impl class +val result = client.executeWorkflow( + GreetingWorkflow::getGreeting, + KWorkflowOptions(workflowId = "greeting-123", taskQueue = "greetings"), + "World" +) +``` + +### Benefits + +- Reduces boilerplate (no separate interface file) +- More similar to Python SDK experience +- Kotlin-only feature, no Java SDK changes required + +### Trade-offs + +- Different from Java SDK convention +- Activity/workflow type names derived from method/class names (convention-based) +- Respects `@ActivityMethod(name = "...")` and `@WorkflowMethod(name = "...")` annotations if present + +### Related Sections + +- [Activity Definition](./activities/definition.md#interfaceless-activity-definition) +- [Workflow Definition](./workflows/definition.md#interfaceless-workflow-definition) + +--- + +## Type-Safe Activity/Workflow Arguments + +**Status:** Decided + +### Decision + +**Option C: KArgs Wrapper Classes** - Use typed `KArgs` classes for 2+ arguments, with simpler direct forms for 0-1 arguments. This provides full compile-time type safety while keeping common cases simple. + +### Problem Statement + +The current activity execution API uses varargs which are not compile-time type-safe: + +```kotlin +// Current approach - vararg, no compile-time type checking +KWorkflow.executeActivity( + GreetingActivities::composeGreeting, + KActivityOptions(startToCloseTimeout = 30.seconds), + "Hello", "World" // vararg Any? - wrong types only caught at runtime +) +``` + +### Options + +Three options are being considered: + +--- + +### Option A: Keep Current Varargs (No Change) + +Keep the current vararg approach: + +```kotlin +KWorkflow.executeActivity( + GreetingActivities::composeGreeting, + KActivityOptions(startToCloseTimeout = 30.seconds), + "Hello", "World" // vararg Any? +) +``` + +**Pros:** +- Simple API, no wrapper classes +- Familiar pattern + +**Cons:** +- No compile-time type safety +- Wrong argument types/count only caught at runtime + +--- + +### Option B: Direct Overloads (0-7 Arguments) + +Provide separate overloads for each arity with direct arguments: + +```kotlin +// 0 arguments +KWorkflow.executeActivity( + GreetingActivities::getDefault, + KActivityOptions(startToCloseTimeout = 30.seconds) +) + +// 1 argument +KWorkflow.executeActivity( + GreetingActivities::greet, + "World", + KActivityOptions(startToCloseTimeout = 30.seconds) +) + +// 2 arguments +KWorkflow.executeActivity( + GreetingActivities::composeGreeting, + "Hello", "World", + KActivityOptions(startToCloseTimeout = 30.seconds) +) + +// 3 arguments +KWorkflow.executeActivity( + OrderActivities::process, + orderId, customer, items, + KActivityOptions(startToCloseTimeout = 30.seconds) +) +``` + +**Overloads:** +```kotlin +object KWorkflow { + suspend fun executeActivity( + activity: KFunction1, + options: KActivityOptions + ): R + + suspend fun executeActivity( + activity: KFunction2, + arg: A, + options: KActivityOptions + ): R + + suspend fun executeActivity( + activity: KFunction3, + arg1: A1, arg2: A2, + options: KActivityOptions + ): R + + suspend fun executeActivity( + activity: KFunction4, + arg1: A1, arg2: A2, arg3: A3, + options: KActivityOptions + ): R + + // ... up to 7 arguments +} +``` + +**Pros:** +- Full compile-time type safety +- Clean call syntax, no wrapper classes +- Natural reading order + +**Cons:** +- Many overloads (8 per method × 3 call types = 24 overloads) +- Options always last (can't use trailing lambda syntax if options were a builder) + +--- + +### Option C: KArgs Wrapper Classes + +Use typed `KArgs` classes for multiple arguments: + +**Activities:** +```kotlin +// 0 arguments - just method reference and options +KWorkflow.executeActivity( + GreetingActivities::getDefaultGreeting, + KActivityOptions(startToCloseTimeout = 30.seconds) +) + +// 1 argument - passed directly +KWorkflow.executeActivity( + GreetingActivities::greet, + "World", + KActivityOptions(startToCloseTimeout = 30.seconds) +) + +// Multiple arguments - use kargs() +KWorkflow.executeActivity( + GreetingActivities::composeGreeting, + kargs("Hello", "World"), + KActivityOptions(startToCloseTimeout = 30.seconds) +) +``` + +**Child Workflows:** +```kotlin +// 0 arguments +KWorkflow.executeChildWorkflow( + ChildWorkflow::run, + KChildWorkflowOptions(workflowId = "child-1") +) + +// 1 argument +KWorkflow.executeChildWorkflow( + ChildWorkflow::process, + order, + KChildWorkflowOptions(workflowId = "child-1") +) + +// Multiple arguments +KWorkflow.executeChildWorkflow( + ChildWorkflow::processWithConfig, + kargs(order, config), + KChildWorkflowOptions(workflowId = "child-1") +) +``` + +**Client Workflow Execution:** +```kotlin +// 0 arguments +client.executeWorkflow( + MyWorkflow::run, + KWorkflowOptions(workflowId = "wf-1", taskQueue = "main") +) + +// 1 argument +client.executeWorkflow( + MyWorkflow::process, + input, + KWorkflowOptions(workflowId = "wf-1", taskQueue = "main") +) + +// Multiple arguments +client.executeWorkflow( + MyWorkflow::processWithConfig, + kargs(input, config), + KWorkflowOptions(workflowId = "wf-1", taskQueue = "main") +) +``` + +**KArgs classes:** +```kotlin +sealed interface KArgs + +data class KArgs2(val a1: A1, val a2: A2) : KArgs +data class KArgs3(val a1: A1, val a2: A2, val a3: A3) : KArgs +data class KArgs4(val a1: A1, val a2: A2, val a3: A3, val a4: A4) : KArgs +data class KArgs5(val a1: A1, val a2: A2, val a3: A3, val a4: A4, val a5: A5) : KArgs +data class KArgs6(val a1: A1, val a2: A2, val a3: A3, val a4: A4, val a5: A5, val a6: A6) : KArgs +data class KArgs7(val a1: A1, val a2: A2, val a3: A3, val a4: A4, val a5: A5, val a6: A6, val a7: A7) : KArgs + +// Factory functions +fun kargs(a1: A1, a2: A2) = KArgs2(a1, a2) +fun kargs(a1: A1, a2: A2, a3: A3) = KArgs3(a1, a2, a3) +// ... up to 7 +``` + +**Execute overloads (activities):** +```kotlin +object KWorkflow { + // 0 arguments + suspend fun executeActivity( + activity: KFunction1, + options: KActivityOptions + ): R + + // 1 argument - direct + suspend fun executeActivity( + activity: KFunction2, + arg: A, + options: KActivityOptions + ): R + + // 2 arguments - KArgs2 types must match KFunction3 params + suspend fun executeActivity( + activity: KFunction3, + args: KArgs2, + options: KActivityOptions + ): R + + // ... up to 7 +} +``` + +**Execute overloads (child workflows):** +```kotlin +object KWorkflow { + // 0 arguments + suspend fun executeChildWorkflow( + workflow: KFunction1, + options: KChildWorkflowOptions + ): R + + // 1 argument + suspend fun executeChildWorkflow( + workflow: KFunction2, + arg: A, + options: KChildWorkflowOptions + ): R + + // 2 arguments + suspend fun executeChildWorkflow( + workflow: KFunction3, + args: KArgs2, + options: KChildWorkflowOptions + ): R + + // ... up to 7 +} +``` + +**Execute overloads (client):** +```kotlin +class KWorkflowClient { + // 0 arguments + suspend fun executeWorkflow( + workflow: KFunction1, + options: KWorkflowOptions + ): R + + // 1 argument + suspend fun executeWorkflow( + workflow: KFunction2, + arg: A, + options: KWorkflowOptions + ): R + + // 2 arguments + suspend fun executeWorkflow( + workflow: KFunction3, + args: KArgs2, + options: KWorkflowOptions + ): R + + // ... up to 7 +} +``` + +**Pros:** +- Full compile-time type safety +- Fewer overloads than Option B (only 0, 1, and KArgs variants = 3 per method) +- Works with Kotlin's type inference + +**Cons:** +- Requires `kargs(...)` wrapper for 2+ arguments +- Additional classes in the API + +--- + +### Comparison + +| Aspect | Option A (Varargs) | Option B (Direct) | Option C (KArgs) | +|--------|-------------------|-------------------|------------------| +| Type safety | Runtime only | Compile-time | Compile-time | +| Call syntax (2+ args) | `"a", "b"` | `"a", "b"` | `kargs("a", "b")` | +| Overloads per method | 1 | 8 | 3 | +| Total overloads | 3 | 24 | 9 | +| Additional classes | None | None | KArgs2-7 | + +### Related Sections + +- [Activity Definition](./activities/definition.md#type-safe-activity-arguments) + +--- + +## Data Classes vs Builder+DSL for Options/Config Classes + +**Status:** Decision needed + +### Problem Statement + +Data classes are convenient for options/config classes due to named parameters and `copy()`: + +```kotlin +data class KActivityOptions( + val startToCloseTimeout: Duration? = null, + val scheduleToCloseTimeout: Duration? = null, + // ... +) + +val options = KActivityOptions(startToCloseTimeout = 10.minutes) +``` + +However, the Kotlin team doesn't recommend using data classes as part of library APIs because adding a new field (even an optional one) is always a **binary breaking change**. This happens because: + +- Adding a new property requires a new constructor parameter +- Even with default values, this changes the constructor signature +- Existing compiled code calling the old constructor breaks at runtime +- The auto-generated `copy()` method has the same issue + +Libraries like Jackson started with constructors with named parameters and later deprecated them in favor of a DSL/builder combo. + +### Proposed Alternative: Builder + DSL Pattern + +```kotlin +class KActivityOptions +private constructor(builder: Builder) { + val startToCloseTimeout: Duration? + val scheduleToCloseTimeout: Duration? + // ... + + init { + startToCloseTimeout = builder.startToCloseTimeout + scheduleToCloseTimeout = builder.scheduleToCloseTimeout + // ... + } + + class Builder { + var startToCloseTimeout: Duration? = null + var scheduleToCloseTimeout: Duration? = null + // ... + + fun build(): KActivityOptions { + require(startToCloseTimeout != null || scheduleToCloseTimeout != null) { + "At least one of startToCloseTimeout or scheduleToCloseTimeout must be specified" + } + return KActivityOptions(this) + } + } +} + +inline fun KActivityOptions(init: KActivityOptions.Builder.() -> Unit): KActivityOptions { + return KActivityOptions.Builder().apply(init).build() +} +``` + +This gives an ABI-safe equivalent for data class named constructor parameters: + +```kotlin +val options = KActivityOptions { + startToCloseTimeout = 10.minutes + // ... +} +``` + +### Benefits of Builder+DSL + +- Adding new optional properties to the `Builder` class is binary compatible +- The inline factory function provides the same ergonomic DSL syntax as data class constructors +- Validation can happen in `build()` before object construction +- `copy` can be implemented safely if needed (via a `toBuilder()` method) + +### Enhanced Nested DSL Ergonomics + +With the Builder+DSL pattern, extension methods can provide cleaner syntax for nested options: + +```kotlin +// Without extension method - requires assignment +val options = KActivityOptions { + startToCloseTimeout = 10.minutes + retryOptions = KRetryOptions { + initialInterval = 10.seconds + backoffCoefficient = 1.5 + } +} + +// With extension method - no assignment needed +val options = KActivityOptions { + startToCloseTimeout = 10.minutes + retryOptions { + initialInterval = 10.seconds + backoffCoefficient = 1.5 + } +} +``` + +The extension method: + +```kotlin +inline fun KActivityOptions.Builder.retryOptions(init: KRetryOptions.Builder.() -> Unit) { + this.retryOptions = KRetryOptions(init) +} +``` + +This provides slightly better ergonomics for nested configuration while maintaining full type safety. + +### Trade-offs + +- More boilerplate code to write and maintain +- Loses data class conveniences (`equals`, `hashCode`, `toString`, `componentN`) + - Though these can be manually implemented or generated +- Slightly more complex internal implementation + +### Precedent + +This pattern is used by: +- kotlinx.serialization +- Ktor +- Jackson (migrated from named parameters to this pattern) diff --git a/kotlin/testing.md b/kotlin/testing.md new file mode 100644 index 0000000..27e07d1 --- /dev/null +++ b/kotlin/testing.md @@ -0,0 +1,356 @@ +# Testing + +The Kotlin SDK provides testing utilities that integrate with `kotlinx.coroutines.test` for testing workflows and activities. + +## Test Environment + +Use `KTestWorkflowEnvironment` to create an in-memory Temporal environment for fast, deterministic tests: + +```kotlin +class OrderWorkflowTest { + private lateinit var testEnv: KTestWorkflowEnvironment + private lateinit var worker: KWorker + private lateinit var client: KClient + + @BeforeEach + fun setup() { + testEnv = KTestWorkflowEnvironment.newInstance() + worker = testEnv.newWorker("test-queue") + client = testEnv.workflowClient + } + + @AfterEach + fun teardown() { + testEnv.close() + } + + @Test + fun `test order workflow completes successfully`() = runTest { + // Register workflow and mock activities + worker.registerWorkflowImplementationTypes(OrderWorkflowImpl::class) + worker.registerActivitiesImplementations(MockOrderActivities()) + testEnv.start() + + // Execute workflow + val result = client.executeWorkflow( + OrderWorkflow::processOrder, + KWorkflowOptions( + workflowId = "test-order-123", + taskQueue = "test-queue" + ), + testOrder + ) + + assertEquals(OrderStatus.COMPLETED, result.status) + } +} +``` + +## KTestWorkflowEnvironment API + +```kotlin +/** + * Kotlin test environment wrapping TestWorkflowEnvironment. + * Provides coroutine-friendly APIs for testing workflows. + */ +class KTestWorkflowEnvironment private constructor( + private val delegate: TestWorkflowEnvironment +) : AutoCloseable { + companion object { + fun newInstance(): KTestWorkflowEnvironment + fun newInstance(options: TestEnvironmentOptions): KTestWorkflowEnvironment + fun newInstance(options: TestEnvironmentOptions.Builder.() -> Unit): KTestWorkflowEnvironment + } + + /** The workflow client for starting and interacting with workflows */ + val workflowClient: KClient + + /** Create a new worker for the given task queue */ + fun newWorker(taskQueue: String): KWorker + fun newWorker(taskQueue: String, options: WorkerOptions.Builder.() -> Unit): KWorker + + /** Start the test environment (workers begin processing) */ + fun start() + + /** Shutdown the test environment */ + override fun close() + + // Time control + /** Current test time */ + val currentTimeMillis: Long + + /** Skip time forward, triggering any timers that fire */ + suspend fun skipTime(duration: Duration) + + /** Sleep real time (for integration-style tests) */ + suspend fun sleep(duration: Duration) + + /** Register a callback for when workflow reaches a timer */ + fun registerDelayCallback(duration: Duration, callback: () -> Unit) + + // Advanced + /** Access the underlying TestWorkflowEnvironment */ + val testEnvironment: TestWorkflowEnvironment +} +``` + +## Time Skipping + +Test long-running workflows without waiting for real time to pass: + +```kotlin +@Test +fun `test workflow with timers`() = runTest { + worker.registerWorkflowImplementationTypes(ReminderWorkflowImpl::class) + testEnv.start() + + // Start workflow that has a 24-hour delay + val handle = client.startWorkflow( + ReminderWorkflow::scheduleReminder, + KWorkflowOptions( + workflowId = "reminder-123", + taskQueue = "test-queue" + ), + reminder + ) + + // Skip 24 hours instantly + testEnv.skipTime(24.hours) + + // Workflow should now be complete + val result = handle.result() + assertTrue(result.reminderSent) +} +``` + +## Mocking Activities + +### Simple Mock Implementation + +```kotlin +class MockOrderActivities : OrderActivities { + var chargePaymentCalled = false + var lastOrder: Order? = null + + override suspend fun chargePayment(order: Order): PaymentResult { + chargePaymentCalled = true + lastOrder = order + return PaymentResult(success = true, transactionId = "mock-tx-123") + } + + override suspend fun shipOrder(order: Order): ShipmentResult { + return ShipmentResult(trackingNumber = "MOCK-TRACK-123") + } +} + +@Test +fun `test payment is charged`() = runTest { + val mockActivities = MockOrderActivities() + worker.registerWorkflowImplementationTypes(OrderWorkflowImpl::class) + worker.registerActivitiesImplementations(mockActivities) + testEnv.start() + + client.executeWorkflow( + OrderWorkflow::processOrder, + options, + testOrder + ) + + assertTrue(mockActivities.chargePaymentCalled) + assertEquals(testOrder, mockActivities.lastOrder) +} +``` + +### Using Mocking Frameworks + +```kotlin +@Test +fun `test with mockk`() = runTest { + val mockActivities = mockk() + coEvery { mockActivities.chargePayment(any()) } returns PaymentResult(success = true, transactionId = "tx-123") + coEvery { mockActivities.shipOrder(any()) } returns ShipmentResult(trackingNumber = "TRACK-123") + + worker.registerWorkflowImplementationTypes(OrderWorkflowImpl::class) + worker.registerActivitiesImplementations(mockActivities) + testEnv.start() + + val result = client.executeWorkflow(OrderWorkflow::processOrder, options, testOrder) + + coVerify { mockActivities.chargePayment(testOrder) } + assertTrue(result.success) +} +``` + +## Testing Signals and Queries + +```kotlin +@Test +fun `test signal updates workflow state`() = runTest { + worker.registerWorkflowImplementationTypes(OrderWorkflowImpl::class) + worker.registerActivitiesImplementations(mockActivities) + testEnv.start() + + val handle = client.startWorkflow( + OrderWorkflow::processOrder, + options, + testOrder + ) + + // Query initial state + val initialStatus = handle.query(OrderWorkflow::status) + assertEquals(OrderStatus.PENDING, initialStatus) + + // Send signal + handle.signal(OrderWorkflow::updatePriority, Priority.HIGH) + + // Query updated state + val priority = handle.query(OrderWorkflow::priority) + assertEquals(Priority.HIGH, priority) +} +``` + +## Testing Updates + +```kotlin +@Test +fun `test update modifies order`() = runTest { + worker.registerWorkflowImplementationTypes(OrderWorkflowImpl::class) + worker.registerActivitiesImplementations(mockActivities) + testEnv.start() + + val handle = client.startWorkflow( + OrderWorkflow::processOrder, + options, + testOrder + ) + + // Execute update + val added = handle.executeUpdate(OrderWorkflow::addItem, newItem) + assertTrue(added) + + // Verify via query + val itemCount = handle.query(OrderWorkflow::getItemCount) + assertEquals(2, itemCount) // Original item + new item +} +``` + +## Testing Cancellation + +```kotlin +@Test +fun `test workflow handles cancellation gracefully`() = runTest { + worker.registerWorkflowImplementationTypes(OrderWorkflowImpl::class) + worker.registerActivitiesImplementations(mockActivities) + testEnv.start() + + val handle = client.startWorkflow( + OrderWorkflow::processOrder, + options, + testOrder + ) + + // Cancel the workflow + handle.cancel() + + // Verify cleanup activities were called + val description = handle.describe() + assertEquals(WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_CANCELED, description.status) +} +``` + +## Testing Child Workflows + +```kotlin +@Test +fun `test parent orchestrates child workflows`() = runTest { + worker.registerWorkflowImplementationTypes( + ParentWorkflowImpl::class, + ChildWorkflowImpl::class + ) + worker.registerActivitiesImplementations(mockActivities) + testEnv.start() + + val result = client.executeWorkflow( + ParentWorkflow::orchestrate, + options, + parentInput + ) + + assertEquals(3, result.childResults.size) +} +``` + +## Integration Testing + +For tests that need a real Temporal server: + +```kotlin +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class OrderWorkflowIntegrationTest { + private lateinit var service: WorkflowServiceStubs + private lateinit var client: KClient + private lateinit var factory: KWorkerFactory + + @BeforeAll + fun setup() { + // Connect to local Temporal server + service = WorkflowServiceStubs.newLocalServiceStubs() + client = KClient(service) + factory = KWorkerFactory(client) + + val worker = factory.newWorker("integration-test-queue") + worker.registerWorkflowImplementationTypes(OrderWorkflowImpl::class) + worker.registerActivitiesImplementations(RealOrderActivities()) + factory.start() + } + + @AfterAll + fun teardown() { + factory.shutdown() + service.shutdown() + } + + @Test + fun `integration test with real server`() = runBlocking { + val result = client.executeWorkflow( + OrderWorkflow::processOrder, + KWorkflowOptions( + workflowId = "integration-test-${UUID.randomUUID()}", + taskQueue = "integration-test-queue" + ), + testOrder + ) + + assertTrue(result.success) + } +} +``` + +## Best Practices + +1. **Use `runTest`** from `kotlinx.coroutines.test` for suspend function tests +2. **Prefer unit tests** with `KTestWorkflowEnvironment` for fast feedback +3. **Use time skipping** for workflows with delays - don't wait for real time +4. **Mock activities** to isolate workflow logic and control external dependencies +5. **Test edge cases**: cancellation, timeouts, failures, retries +6. **Use unique workflow IDs** in integration tests to avoid conflicts + +## Dependencies + +Add the testing dependency to your project: + +```kotlin +// build.gradle.kts +testImplementation("io.temporal:temporal-testing:$temporalVersion") +testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion") +``` + +## Related + +- [Workflow Definition](./workflows/definition.md) - Defining testable workflows +- [Activities](./activities/README.md) - Defining testable activities +- [Worker Setup](./worker/setup.md) - Production worker configuration + +--- + +**Next:** [Migration Guide](./migration.md) diff --git a/kotlin/worker/README.md b/kotlin/worker/README.md new file mode 100644 index 0000000..23b6e29 --- /dev/null +++ b/kotlin/worker/README.md @@ -0,0 +1,52 @@ +# Worker API + +This section covers setting up workers to execute Temporal workflows and activities in Kotlin. + +## Overview + +The Kotlin SDK provides `KWorkerFactory` and `KWorker` which automatically enable coroutine support for Kotlin workflows and suspend activities. + +## Documents + +| Document | Description | +|----------|-------------| +| [Setup](./setup.md) | KWorkerFactory, KWorker, KotlinPlugin, registration | + +## Quick Reference + +### Basic Worker Setup + +```kotlin +val client = KClient.connect(KClientOptions(target = "localhost:7233")) + +// Create worker with workflows and activities at construction time +val worker = KWorker( + client, + KWorkerOptions( + taskQueue = "task-queue", + workflows = listOf(MyWorkflowImpl::class), + activities = listOf(MyActivitiesImpl()) + ) +) + +// Run the worker (blocks until shutdown or fatal error) +worker.run() +``` + +### Key Components + +| Component | Purpose | +|-----------|---------| +| `KWorker` | Creates and runs workers with automatic coroutine support | +| `KWorkerOptions` | Configuration including workflows and activities | +| `KotlinJavaWorkerPlugin` | Plugin for Java main apps using Kotlin workflows | + +## Related + +- [Interceptors](../configuration/interceptors.md) - Cross-cutting concerns +- [Workflows](../workflows/README.md) - Defining workflows to register +- [Activities](../activities/README.md) - Defining activities to register + +--- + +**Next:** [Worker Setup](./setup.md) diff --git a/kotlin/worker/setup.md b/kotlin/worker/setup.md new file mode 100644 index 0000000..4c6d9c6 --- /dev/null +++ b/kotlin/worker/setup.md @@ -0,0 +1,192 @@ +# Worker Setup + +## KWorker (Recommended) + +For pure Kotlin applications, use `KWorker` which automatically enables coroutine support. Following the Python/.NET pattern, workflows and activities are passed at construction time via options: + +```kotlin +val client = KClient.connect(KClientOptions(target = "localhost:7233")) + +// Create worker with workflows and activities specified in options +val worker = KWorker( + client, + KWorkerOptions( + taskQueue = "task-queue", + workflows = listOf( + GreetingWorkflowImpl::class, + OrderWorkflowImpl::class + ), + activities = listOf( + GreetingActivitiesImpl(), // Kotlin suspend activities + JavaActivitiesImpl() // Java activities work too + ), + maxConcurrentActivityExecutionSize = 100 + ) +) + +// Run the worker (blocks until shutdown signal or fatal error) +worker.run() +``` + +## KWorker API + +```kotlin +/** + * Kotlin worker that provides idiomatic APIs for running + * Kotlin workflows and suspend activities. + * + * Workflows and activities are passed at construction time via KWorkerOptions, + * following the Python/.NET SDK pattern. + */ +class KWorker( + client: KClient, + options: KWorkerOptions +) { + /** The underlying Java Worker for interop scenarios */ + val worker: Worker + + /** + * Run the worker, blocking until a shutdown signal is received or a fatal error occurs. + * This is the recommended way to run a worker. + * + * Unlike start()/shutdown(), this method propagates fatal worker-runtime errors + * rather than silently swallowing them. + * + * @throws WorkerException if a fatal error occurs during worker execution + */ + suspend fun run() + + /** + * Start the worker without blocking. Use shutdown() to stop. + * Prefer run() for most use cases as it properly propagates fatal errors. + */ + fun start() + + /** Gracefully shut down the worker, allowing in-flight tasks to complete. */ + fun shutdown() + + /** Immediately shut down the worker, cancelling in-flight tasks. */ + fun shutdownNow() + + /** Wait for the worker to terminate after shutdown is called. */ + suspend fun awaitTermination(timeout: Duration) +} +``` + +## KWorkerOptions + +```kotlin +/** + * Options for configuring a KWorker. + * Workflows and activities are specified at construction time. + */ +data class KWorkerOptions( + val taskQueue: String, + val workflows: List> = emptyList(), + val activities: List = emptyList(), + val workflowImplementationOptions: WorkflowImplementationOptions? = null, + val maxConcurrentActivityExecutionSize: Int? = null, + val maxConcurrentWorkflowTaskExecutionSize: Int? = null, + val maxConcurrentLocalActivityExecutionSize: Int? = null, + // ... other worker options +) +``` + +## Multiple Workers + +For multiple task queues, create multiple workers and run them concurrently: + +```kotlin +val orderWorker = KWorker( + client, + KWorkerOptions( + taskQueue = "orders", + workflows = listOf(OrderWorkflowImpl::class), + activities = listOf(OrderActivitiesImpl()) + ) +) + +val notificationWorker = KWorker( + client, + KWorkerOptions( + taskQueue = "notifications", + workflows = listOf(NotificationWorkflowImpl::class), + activities = listOf(NotificationActivitiesImpl()) + ) +) + +// Run all workers concurrently - blocks until shutdown or fatal error +coroutineScope { + launch { orderWorker.run() } + launch { notificationWorker.run() } +} +``` + +For advanced scenarios where you need manual control: + +```kotlin +// Start workers without blocking +orderWorker.start() +notificationWorker.start() + +// ... do other work ... + +// Graceful shutdown +orderWorker.shutdown() +notificationWorker.shutdown() +orderWorker.awaitTermination(30.seconds) +notificationWorker.awaitTermination(30.seconds) +``` + +## KotlinJavaWorkerPlugin (For Java Main) + +When your main application is written in Java and you need to register Kotlin workflows, use `KotlinJavaWorkerPlugin` explicitly: + +```kotlin +// Java main or mixed Java/Kotlin setup +val service = WorkflowServiceStubs.newLocalServiceStubs() +val client = WorkflowClient.newInstance(service) + +val factory = WorkerFactory.newInstance(client, WorkerFactoryOptions.newBuilder() + .addPlugin(KotlinJavaWorkerPlugin()) + .build()) + +val worker = factory.newWorker("task-queue") + +// Register Kotlin workflows - plugin handles suspend functions +worker.registerWorkflowImplementationTypes(KotlinWorkflowImpl::class.java) +``` + +## Mixed Java and Kotlin + +A single worker supports both Java and Kotlin workflows on the same task queue: + +```kotlin +val worker = KWorker( + client, + KWorkerOptions( + taskQueue = "mixed-queue", + workflows = listOf( + GreetingWorkflowImpl::class, // Kotlin workflow (coroutine-based) + OrderWorkflowJavaImpl::class // Java workflow (thread-based) + ), + activities = listOf( + KotlinActivitiesImpl(), // Kotlin suspend activities + JavaActivitiesImpl() // Java blocking activities + ) + ) +) + +// Both run on the same worker - execution model is per-workflow-instance +worker.run() // Blocks until shutdown or fatal error +``` + +## Related + +- [Interceptors](../configuration/interceptors.md) - Adding cross-cutting concerns +- [Workflows](../workflows/README.md) - Defining workflows to register +- [Activities](../activities/README.md) - Defining activities to register + +--- + +**Next:** [Testing](../testing.md) diff --git a/kotlin/workflows/README.md b/kotlin/workflows/README.md new file mode 100644 index 0000000..fd3e2ed --- /dev/null +++ b/kotlin/workflows/README.md @@ -0,0 +1,67 @@ +# Workflows + +This section covers defining and implementing Temporal workflows in Kotlin. + +## Overview + +Kotlin workflows use coroutines and suspend functions for an idiomatic async experience while maintaining Temporal's determinism guarantees. + +## Documents + +| Document | Description | +|----------|-------------| +| [Definition](./definition.md) | Workflow interfaces, suspend methods, Java interop patterns | +| [Signals, Queries & Updates](./signals-queries.md) | Communication with running workflows | +| [Child Workflows](./child-workflows.md) | Orchestrating child workflow execution | +| [External Workflows](./external-workflows.md) | Signal or cancel workflows in other executions | +| [Timers & Parallel Execution](./timers-parallel.md) | Delays, async patterns, await conditions | +| [Cancellation](./cancellation.md) | Handling cancellation, cleanup with NonCancellable | +| [Continue-As-New](./continue-as-new.md) | Long-running workflow patterns | + +## Quick Reference + +### Basic Workflow + +```kotlin +@WorkflowInterface +interface GreetingWorkflow { + suspend fun getGreeting(name: String): String +} + +// Use @WorkflowMethod only when customizing the workflow type name +@WorkflowInterface +interface CustomNameWorkflow { + @WorkflowMethod(name = "CustomGreeting") + suspend fun getGreeting(name: String): String +} + +class GreetingWorkflowImpl : GreetingWorkflow { + override suspend fun getGreeting(name: String): String { + return KWorkflow.executeActivity( + GreetingActivities::composeGreeting, + KActivityOptions(startToCloseTimeout = 10.seconds), + "Hello", name + ) + } +} +``` + +### Key Patterns + +| Pattern | Kotlin SDK | +|---------|------------| +| Execute activity | `KWorkflow.executeActivity(Interface::method, options, args)` | +| Execute child workflow | `KWorkflow.executeChildWorkflow(Interface::method, options, args)` | +| Timer/delay | `delay(duration)` - standard kotlinx.coroutines | +| Wait for condition | `KWorkflow.awaitCondition { condition }` | +| Parallel execution | `coroutineScope { async { ... } }.awaitAll()` | +| Cancellation cleanup | `withContext(NonCancellable) { ... }` | + +## Related + +- [Activities](../activities/README.md) - What workflows orchestrate +- [Client](../client/README.md) - Starting and interacting with workflows + +--- + +**Next:** [Workflow Definition](./definition.md) diff --git a/kotlin/workflows/cancellation.md b/kotlin/workflows/cancellation.md new file mode 100644 index 0000000..baba224 --- /dev/null +++ b/kotlin/workflows/cancellation.md @@ -0,0 +1,155 @@ +# Cancellation + +Kotlin's built-in coroutine cancellation replaces Java SDK's `CancellationScope`. This provides a more idiomatic experience while maintaining full Temporal semantics. + +## How Cancellation Works + +When a workflow is cancelled (from the server or programmatically), the Kotlin SDK translates this into coroutine cancellation. Cancellation is **cooperative**—it's checked at suspension points (`delay`, activity execution, child workflow execution, etc.): + +```kotlin +override suspend fun processOrder(order: Order): OrderResult = coroutineScope { + // Cancellation is checked at each suspension point + val validated = KWorkflow.executeActivity(...) // ← cancellation checked here + delay(5.minutes) // ← cancellation checked here + val result = KWorkflow.executeActivity(...) // ← cancellation checked here + result +} +``` + +## Explicit Cancellation Checks + +In rare cases where workflow code doesn't have suspension points (e.g., tight loops), you can check cancellation explicitly using `isActive` or `ensureActive()`. However, CPU-bound work should typically be delegated to activities. + +## Parallel Execution and Cancellation + +`coroutineScope` provides structured concurrency—if one child fails or the scope is cancelled, all children are automatically cancelled. + +**Note:** This is standard Kotlin `coroutineScope` exception propagation behavior, not Temporal-specific: +- When one child fails with an exception, `coroutineScope` cancels all other children +- This is automatic Kotlin structured concurrency behavior + +```kotlin +override suspend fun parallelWorkflow(): String = coroutineScope { + val a = async { KWorkflow.executeActivity(...) } + val b = async { KWorkflow.executeActivity(...) } + + // If either activity fails, the other is cancelled (standard Kotlin behavior) + // If workflow is cancelled, both activities are cancelled + "${a.await()} - ${b.await()}" +} +``` + +## Detached Scopes (Cleanup Logic) + +Use `withContext(NonCancellable)` for cleanup code that must run even when the workflow is cancelled: + +```kotlin +override suspend fun processOrder(order: Order): OrderResult { + try { + return doProcessOrder(order) + } catch (e: CancellationException) { + // Cleanup runs even though workflow is cancelled + withContext(NonCancellable) { + KWorkflow.executeActivity( + OrderActivities::releaseReservation, + KActivityOptions(startToCloseTimeout = 30.seconds), + order + ) + } + throw e // Re-throw to propagate cancellation + } +} +``` + +This is equivalent to Java's `Workflow.newDetachedCancellationScope()`. + +## Cancellation with Timeout + +Use `withTimeout` to cancel a block after a duration: + +```kotlin +override suspend fun processWithDeadline(order: Order): OrderResult { + return withTimeout(1.hours) { + // Everything in this block is cancelled if it takes > 1 hour + val validated = KWorkflow.executeActivity(...) + val charged = KWorkflow.executeActivity(...) + OrderResult(success = true) + } +} + +// Or use withTimeoutOrNull to get null instead of exception +override suspend fun tryProcess(order: Order): OrderResult? { + return withTimeoutOrNull(30.minutes) { + KWorkflow.executeActivity(...) + } +} +``` + +## Complete Cleanup Example + +```kotlin +class OrderWorkflowImpl : OrderWorkflow { + private var paymentCharged = false + private var reservedItems = mutableListOf() + + override suspend fun processOrder(order: Order): OrderResult { + return try { + doProcessOrder(order) + } catch (e: CancellationException) { + // Workflow was cancelled - run cleanup in detached scope + withContext(NonCancellable) { + cleanup(order) + } + throw e // Re-throw to complete workflow as cancelled + } + } + + private suspend fun cleanup(order: Order) { + // Release any reserved inventory + for (item in reservedItems) { + try { + KWorkflow.executeActivity( + OrderActivities::releaseInventory, + KActivityOptions(startToCloseTimeout = 30.seconds), + item + ) + } catch (e: Exception) { + // Log but continue cleanup + } + } + + // Refund payment if it was charged + if (paymentCharged) { + try { + KWorkflow.executeActivity( + OrderActivities::refundPayment, + KActivityOptions(startToCloseTimeout = 30.seconds), + order + ) + } catch (e: Exception) { + // Log but continue cleanup + } + } + } +} +``` + +## Comparison with Java SDK + +| Java SDK | Kotlin SDK | +|----------|------------| +| `Workflow.newCancellationScope(() -> { ... })` | `coroutineScope { ... }` | +| `Workflow.newDetachedCancellationScope(() -> { ... })` | `withContext(NonCancellable) { ... }` | +| `CancellationScope.cancel()` | `job.cancel()` | +| `CancellationScope.isCancelRequested()` | `!isActive` | +| `CancellationScope.throwCanceled()` | `ensureActive()` | +| `scope.run()` with timeout | `withTimeout(duration) { ... }` | + +## Related + +- [Timers & Parallel](./timers-parallel.md) - Timeout patterns +- [Child Workflows](./child-workflows.md) - Cancellation propagation to children + +--- + +**Next:** [Continue-As-New](./continue-as-new.md) diff --git a/kotlin/workflows/child-workflows.md b/kotlin/workflows/child-workflows.md new file mode 100644 index 0000000..2b07d7a --- /dev/null +++ b/kotlin/workflows/child-workflows.md @@ -0,0 +1,220 @@ +# Child Workflows + +Child workflows are invoked using direct method references - no stub creation needed. + +## Execute and Wait + +```kotlin +// Simple case - execute child workflow and wait for result +override suspend fun parentWorkflow(): String { + return KWorkflow.executeChildWorkflow( + ChildWorkflow::doWork, + KChildWorkflowOptions(workflowId = "child-workflow-id"), + "input" + ) +} + +// With retry options +override suspend fun parentWorkflowWithRetry(): String { + return KWorkflow.executeChildWorkflow( + ChildWorkflow::doWork, + KChildWorkflowOptions( + workflowId = "child-workflow-id", + workflowExecutionTimeout = 1.hours, + retryOptions = KRetryOptions(maximumAttempts = 3) + ), + "input" + ) +} +``` + +## Parallel Execution + +Use standard `coroutineScope { async {} }` for parallel child workflows: + +```kotlin +override suspend fun parentWorkflowParallel(): String = coroutineScope { + // Start child and activity in parallel using standard Kotlin async + val childDeferred = async { + KWorkflow.executeChildWorkflow( + ChildWorkflow::doWork, + KChildWorkflowOptions(workflowId = "child-workflow-id"), + "input" + ) + } + val activityDeferred = async { + KWorkflow.executeActivity( + SomeActivities::doSomething, + KActivityOptions(startToCloseTimeout = 30.seconds) + ) + } + + // Wait for both using standard awaitAll + val (childResult, activityResult) = awaitAll(childDeferred, activityDeferred) + "$childResult - $activityResult" +} +``` + +## Child Workflow Handles + +For cases where you need to interact with a child workflow (signal, query, cancel) rather than just wait for its result, use `startChildWorkflow` to get a handle: + +```kotlin +// Start child workflow and get handle for interaction +override suspend fun parentWorkflowWithHandle(): String { + val handle = KWorkflow.startChildWorkflow( + ChildWorkflow::doWork, + KChildWorkflowOptions(workflowId = "child-workflow-id"), + "input" + ) + + // Can signal the child workflow + handle.signal(ChildWorkflow::updateProgress, 50) + + // Wait for result when ready + return handle.result() +} + +// Parallel child workflows with handles for interaction +override suspend fun parallelChildrenWithHandles(): List = coroutineScope { + val handles = listOf("child-1", "child-2", "child-3").map { id -> + KWorkflow.startChildWorkflow( + ChildWorkflow::doWork, + KChildWorkflowOptions(workflowId = id), + "input" + ) + } + + // Can interact with any child while they're running + handles.forEach { handle -> + handle.signal(ChildWorkflow::updatePriority, Priority.HIGH) + } + + // Wait for all results + handles.map { async { it.result() } }.awaitAll() +} +``` + +## KWorkflow Child Workflow Methods + +```kotlin +object KWorkflow { + /** + * Execute a child workflow and wait for its result. + * For fire-and-wait cases where you don't need to interact with the child. + */ + suspend fun executeChildWorkflow( + workflow: KFunction2, + options: KChildWorkflowOptions, + arg: A1 + ): R + + suspend fun executeChildWorkflow( + workflow: KFunction1, + options: KChildWorkflowOptions + ): R + + // ... up to 6 arguments + + /** + * Start a child workflow and return a handle for interaction. + * Use this when you need to signal, query, or cancel the child workflow. + * For simple fire-and-wait cases, prefer executeChildWorkflow() instead. + */ + suspend fun startChildWorkflow( + workflow: KFunction2, + options: KChildWorkflowOptions, + arg: A1 + ): KChildWorkflowHandle + + suspend fun startChildWorkflow( + workflow: KFunction1, + options: KChildWorkflowOptions + ): KChildWorkflowHandle + + suspend fun startChildWorkflow( + workflow: KFunction3, + options: KChildWorkflowOptions, + arg1: A1, + arg2: A2 + ): KChildWorkflowHandle + + // ... up to 6 arguments +} +``` + +## KChildWorkflowHandle API + +```kotlin +/** + * Handle for interacting with a started child workflow. + * Returned by startChildWorkflow() with result type captured from method reference. + * + * @param T The child workflow interface type + * @param R The result type of the child workflow method + */ +interface KChildWorkflowHandle { + /** The child workflow's workflow ID (available immediately) */ + val workflowId: String + + /** + * Get the child workflow's run ID. + * Suspends until the child workflow starts. + */ + suspend fun runId(): String + + /** + * Wait for the child workflow to complete and return its result. + * Suspends until the child workflow finishes. + */ + suspend fun result(): R + + // Signals - type-safe method references + suspend fun signal(method: KFunction1) + suspend fun signal(method: KFunction2, arg: A1) + suspend fun signal(method: KFunction3, arg1: A1, arg2: A2) + + /** + * Request cancellation of the child workflow. + * The child workflow will receive a CancellationException at its next suspension point. + */ + suspend fun cancel() +} +``` + +## KChildWorkflowOptions + +```kotlin +// All fields are optional - null values inherit from parent workflow or use Java SDK defaults +KChildWorkflowOptions( + workflowId = "child-workflow-id", + taskQueue = "child-queue", // Optional: defaults to parent's task queue + workflowExecutionTimeout = 1.hours, + workflowRunTimeout = 30.minutes, + retryOptions = KRetryOptions( + initialInterval = 1.seconds, + maximumAttempts = 3 + ), + parentClosePolicy = ParentClosePolicy.PARENT_CLOSE_POLICY_TERMINATE, // Optional + cancellationType = ChildWorkflowCancellationType.WAIT_CANCELLATION_COMPLETED // Optional +) + +// Minimal options - just use defaults +val result = KWorkflow.executeChildWorkflow( + ChildWorkflow::processData, + KChildWorkflowOptions(), + inputData +) +``` + +> **Note:** For simple parallel execution where you only need the result, use standard `coroutineScope { async { executeChildWorkflow(...) } }`. Use `startChildWorkflow` only when you need to interact with the child workflow while it's running. + +## Related + +- [External Workflows](./external-workflows.md) - Signal/cancel workflows in other executions +- [Cancellation](./cancellation.md) - How cancellation propagates to child workflows +- [Workflow Definition](./definition.md) - Basic workflow patterns + +--- + +**Next:** [External Workflows](./external-workflows.md) diff --git a/kotlin/workflows/continue-as-new.md b/kotlin/workflows/continue-as-new.md new file mode 100644 index 0000000..c90d7ab --- /dev/null +++ b/kotlin/workflows/continue-as-new.md @@ -0,0 +1,150 @@ +# Continue-As-New + +Continue-as-new completes the current workflow execution and immediately starts a new execution with fresh event history. This is essential for: + +- **Preventing history growth**: Long-running workflows accumulate event history which can impact performance. Continue-as-new resets the history. +- **Batch processing**: Process a batch of items, then continue-as-new with the next batch offset. +- **Implementing loops**: Instead of infinite loops that accumulate history, use continue-as-new. + +## Basic Usage + +```kotlin +// Basic continue-as-new - same workflow type, inherit all options +KWorkflow.continueAsNew(nextBatchId, newOffset) + +// With modified options +KWorkflow.continueAsNew( + KContinueAsNewOptions( + taskQueue = "high-priority-queue", + workflowRunTimeout = 2.hours + ), + nextBatchId, newOffset +) + +// Continue as different workflow type (for versioning/migration) +KWorkflow.continueAsNew( + "OrderProcessorV2", + KContinueAsNewOptions(taskQueue = "orders-v2"), + migratedState +) + +// Type-safe continue as different workflow using method reference +KWorkflow.continueAsNew( + OrderProcessorV2::process, + KContinueAsNewOptions(), + migratedState +) +``` + +## KContinueAsNewOptions + +```kotlin +/** + * Options for continuing a workflow as a new execution. + * All fields are optional - null values inherit from the current workflow. + */ +data class KContinueAsNewOptions( + val workflowRunTimeout: Duration? = null, + val taskQueue: String? = null, + val retryOptions: KRetryOptions? = null, + val workflowTaskTimeout: Duration? = null, + val memo: Map? = null, + val searchAttributes: SearchAttributes? = null +) +``` + +## Batch Processing Pattern + +```kotlin +class BatchProcessorImpl : BatchProcessor { + override suspend fun processBatches(startOffset: Int) { + val batchSize = 100 + val items = KWorkflow.executeActivity( + DataActivities::fetchBatch, + KActivityOptions(startToCloseTimeout = 1.minutes), + startOffset, batchSize + ) + + if (items.isEmpty()) { + return // All done, workflow completes normally + } + + // Process items... + for (item in items) { + KWorkflow.executeActivity( + DataActivities::processItem, + KActivityOptions(startToCloseTimeout = 30.seconds), + item + ) + } + + // Continue with the next batch + KWorkflow.continueAsNew(startOffset + batchSize) + } +} +``` + +## History Size Check Pattern + +```kotlin +override suspend fun execute(state: WorkflowState) { + while (true) { + // Check if history is getting too large + if (KWorkflow.info.isContinueAsNewSuggested) { + KWorkflow.continueAsNew(state) + } + + // Wait for signals and process... + KWorkflow.awaitCondition { hasNewWork } + processWork() + } +} +``` + +## KWorkflow.continueAsNew API + +```kotlin +object KWorkflow { + /** + * Continue workflow as new with the same workflow type. + * This function never returns - it terminates the current execution. + */ + fun continueAsNew(vararg args: Any?): Nothing + + /** + * Continue workflow as new with modified options. + * Null option values inherit from the current workflow. + */ + fun continueAsNew(options: KContinueAsNewOptions, vararg args: Any?): Nothing + + /** + * Continue as a different workflow type. + * Useful for workflow versioning or migration. + */ + fun continueAsNew( + workflowType: String, + options: KContinueAsNewOptions, + vararg args: Any? + ): Nothing + + /** + * Type-safe continue as different workflow using method reference. + */ + fun continueAsNew( + workflow: KFunction<*>, + options: KContinueAsNewOptions, + vararg args: Any? + ): Nothing +} +``` + +> **Important:** `continueAsNew` never returns normally. It terminates the current workflow execution and signals Temporal to start a new execution. The return type `Nothing` indicates this in Kotlin's type system. + +## Related + +- [Workflow Definition](./definition.md) - Basic workflow patterns +- [Migration Guide](../migration.md) - Java SDK comparison + +--- + +**Next:** [Activities](../activities/README.md) diff --git a/kotlin/workflows/definition.md b/kotlin/workflows/definition.md new file mode 100644 index 0000000..c61f5e1 --- /dev/null +++ b/kotlin/workflows/definition.md @@ -0,0 +1,202 @@ +# Workflow Definition + +Define workflow interfaces with `suspend` methods for full Kotlin coroutine support: + +```kotlin +@WorkflowInterface +interface GreetingWorkflow { + suspend fun getGreeting(name: String): String +} + +// Use @WorkflowMethod only when customizing the workflow type name +@WorkflowInterface +interface CustomNameWorkflow { + @WorkflowMethod(name = "CustomGreeting") + suspend fun getGreeting(name: String): String +} + +class GreetingWorkflowImpl : GreetingWorkflow { + override suspend fun getGreeting(name: String): String { + return KWorkflow.executeActivity( + GreetingActivities::composeGreeting, + KActivityOptions(startToCloseTimeout = 10.seconds), + "Hello", name + ) + } +} + +// Client call using KClient - same pattern as activities, no stub needed +val result = client.executeWorkflow( + GreetingWorkflow::getGreeting, + KWorkflowOptions( + workflowId = "greeting-123", + taskQueue = "greetings" + ), + "Temporal" +) +``` + +## Java Interoperability + +### Kotlin Calling Java Workflows + +Kotlin clients can call Java workflows using typed method references: + +```kotlin +// Java workflow interface (defined in Java) +// @WorkflowInterface +// public interface OrderWorkflow { +// @WorkflowMethod +// OrderResult processOrder(Order order); +// } + +// Kotlin client calling Java workflow - works seamlessly +val result: OrderResult = client.executeWorkflow( + OrderWorkflow::processOrder, + KWorkflowOptions( + workflowId = "order-123", + taskQueue = "orders" + ), + order +) +``` + +### Java Calling Kotlin Workflows + +Java clients can invoke Kotlin suspend workflows using **untyped workflow stubs**. The workflow type name defaults to the interface name. + +```java +// Java client calling a Kotlin suspend workflow +WorkflowStub stub = client.newUntypedWorkflowStub( + "GreetingWorkflow", // Workflow type = interface name + WorkflowOptions.newBuilder() + .setTaskQueue("greetings") + .setWorkflowId("greeting-123") + .build() +); + +stub.start("Temporal"); +String result = stub.getResult(String.class); +``` + +Alternatively, Java clients can define their own Java interface with the **same name** as the Kotlin interface to use typed stubs: + +```java +// Java interface matching the Kotlin workflow type +@WorkflowInterface +public interface GreetingWorkflow { + String getGreeting(String name); // @WorkflowMethod optional in Java too +} + +// Java client using typed stub +GreetingWorkflow workflow = client.newWorkflowStub( + GreetingWorkflow.class, + WorkflowOptions.newBuilder() + .setTaskQueue("greetings") + .setWorkflowId("greeting-123") + .build() +); +String result = workflow.getGreeting("Temporal"); +``` + +> **Note:** Java cannot directly use Kotlin suspend interfaces because suspend functions compile to methods with an extra `Continuation` parameter. The untyped stub approach is recommended for Java clients. + +## Parameter Restrictions + +**Default parameter values are allowed for methods with 0 or 1 arguments.** For methods with 2+ arguments, defaults are not allowed. This is validated at worker registration time. + +```kotlin +// ✓ ALLOWED - 1 argument with default +@WorkflowMethod +suspend fun processOrder(priority: Int = 0): OrderResult + +// ✗ NOT ALLOWED - 2+ arguments with defaults +@WorkflowMethod +suspend fun processOrder(orderId: String, priority: Int = 0) // Error! + +// ✓ CORRECT for 2+ arguments - use a parameter object with optional fields +data class ProcessOrderParams( + val orderId: String, + val priority: Int? = null +) + +@WorkflowMethod +suspend fun processOrder(params: ProcessOrderParams): OrderResult +``` + +**Rationale:** This aligns with Python, .NET, and Ruby SDKs which support defaults. For complex inputs with multiple parameters, the parameter object pattern avoids serialization ambiguity and cross-language issues. See [full discussion](../open-questions.md#default-parameter-values). + +## Key Characteristics + +* Use `coroutineScope`, `async`, `launch` for concurrent execution +* Use `delay()` for timers (maps to Temporal timers, not `Thread.sleep`) +* Reuses `@WorkflowInterface` annotation from Java SDK +* Method annotations (`@WorkflowMethod`, `@SignalMethod`, etc.) are optional - use only when customizing names +* Data classes work naturally for parameters and results + +> **Note:** Versioning (`KWorkflow.version()`), side effects (`KWorkflow.sideEffect`), and search attributes (`KWorkflow.upsertSearchAttributes`) use the same patterns as the Java SDK. Logging uses standard logging frameworks with MDC - the SDK automatically populates MDC with workflow context (workflowId, runId, taskQueue, etc.). + +## Related + +- [Child Workflows](./child-workflows.md) - Orchestrating child workflows +- [Timers & Parallel Execution](./timers-parallel.md) - Delays and async patterns + +--- + +## Open Questions (Decision Needed) + +### Interfaceless Workflow Definition + +**Status:** Decision needed | [Full discussion](../open-questions.md#interfaceless-workflows-and-activities) + +Currently, workflows require interface definitions: + +```kotlin +// Current approach - requires interface +@WorkflowInterface +interface GreetingWorkflow { + @WorkflowMethod + suspend fun getGreeting(name: String): String +} + +class GreetingWorkflowImpl : GreetingWorkflow { + override suspend fun getGreeting(name: String): String { + // implementation + } +} +``` + +**Proposal:** Allow defining workflows directly on implementation classes without interfaces, similar to Python SDK: + +```kotlin +// Proposed approach - no interface required +class GreetingWorkflow { + suspend fun getGreeting(name: String): String { + return KWorkflow.executeActivity( + GreetingActivities::composeGreeting, + KActivityOptions(startToCloseTimeout = 10.seconds), + "Hello", name + ) + } +} + +// Client call using method reference to impl class +val result = client.executeWorkflow( + GreetingWorkflow::getGreeting, + KWorkflowOptions(workflowId = "greeting-123", taskQueue = "greetings"), + "World" +) +``` + +**Benefits:** +- Reduces boilerplate (no separate interface file) +- More similar to Python SDK experience +- Kotlin-only feature, no Java SDK changes required + +**Trade-offs:** +- Different from Java SDK convention +- Workflow type name derived from class name (convention-based, respects `@WorkflowMethod(name = "...")`) + +--- + +**Next:** [Signals, Queries & Updates](./signals-queries.md) diff --git a/kotlin/workflows/external-workflows.md b/kotlin/workflows/external-workflows.md new file mode 100644 index 0000000..6923d0b --- /dev/null +++ b/kotlin/workflows/external-workflows.md @@ -0,0 +1,115 @@ +# External Workflows + +External workflows allow you to signal or cancel workflows running in separate executions. Use `KWorkflow.getExternalWorkflowHandle()` to get a handle for interaction. + +## Typed Handle + +```kotlin +// Get typed handle for external workflow +val handle = KWorkflow.getExternalWorkflowHandle("order-123") + +// Signal using method reference (type-safe) +handle.signal(OrderWorkflow::updatePriority, Priority.HIGH) + +// Cancel the external workflow +handle.cancel() +``` + +## With Run ID + +```kotlin +// Target a specific execution +val handle = KWorkflow.getExternalWorkflowHandle( + workflowId = "order-123", + runId = "abc-run-456" +) + +handle.signal(OrderWorkflow::cancelOrder, "Duplicate order") +``` + +## Untyped Handle + +When the workflow type is unknown at compile time: + +```kotlin +// Get untyped handle +val handle = KWorkflow.getExternalWorkflowHandle("order-123") + +// Signal by name +handle.signal("updatePriority", Priority.HIGH) + +// Cancel +handle.cancel() +``` + +## KExternalWorkflowHandle API + +```kotlin +/** + * Handle for interacting with an external workflow (workflow in a different execution). + * Obtained via KWorkflow.getExternalWorkflowHandle(). + * + * @param T The external workflow interface type (for type-safe signals) + */ +class KExternalWorkflowHandle( + val workflowId: String, + val runId: String? +) { + // Signals - type-safe method references + suspend fun signal(method: KFunction1) + suspend fun signal(method: KFunction2, arg: A1) + suspend fun signal(method: KFunction3, arg1: A1, arg2: A2) + // ... up to 6 arguments + + /** Request cancellation of the external workflow. */ + suspend fun cancel() +} +``` + +## ExternalWorkflowHandle (Untyped) + +```kotlin +/** + * Untyped handle for external workflows. + * Use when workflow type is unknown at compile time. + */ +class ExternalWorkflowHandle( + val workflowId: String, + val runId: String? +) { + suspend fun signal(signalName: String, vararg args: Any?) + suspend fun cancel() +} +``` + +## KWorkflow API + +```kotlin +object KWorkflow { + /** Get typed handle for external workflow */ + inline fun getExternalWorkflowHandle(workflowId: String): KExternalWorkflowHandle + inline fun getExternalWorkflowHandle(workflowId: String, runId: String): KExternalWorkflowHandle + + /** Get untyped handle for external workflow */ + fun getExternalWorkflowHandle(workflowId: String): ExternalWorkflowHandle + fun getExternalWorkflowHandle(workflowId: String, runId: String): ExternalWorkflowHandle +} +``` + +## Limitations + +| Operation | Supported | +|-----------|-----------| +| Signal | ✓ | +| Cancel | ✓ | +| Query | ✗ (queries are synchronous, only available via client) | +| Get Result | ✗ (cannot await external workflow results from within a workflow) | + +## Related + +- [Child Workflows](./child-workflows.md) - Workflows started by the current workflow +- [Signals, Queries & Updates](./signals-queries.md) - Signal handler definitions + +--- + +**Next:** [Timers & Parallel Execution](./timers-parallel.md) diff --git a/kotlin/workflows/signals-queries.md b/kotlin/workflows/signals-queries.md new file mode 100644 index 0000000..a3ce44d --- /dev/null +++ b/kotlin/workflows/signals-queries.md @@ -0,0 +1,227 @@ +# Signals, Queries, and Updates + +Signals and updates follow the same suspend/non-suspend pattern as workflow methods. Queries are always synchronous (never suspend) and can be defined as properties. + +> **Note:** Default parameter values are allowed for handlers with 0 or 1 arguments. For handlers with 2+ arguments, use parameter objects with optional fields instead. See [Parameter Restrictions](../open-questions.md#default-parameter-values). + +## Defining Handlers + +Method annotations are optional - use only when customizing handler names: + +```kotlin +@WorkflowInterface +interface OrderWorkflow { + suspend fun processOrder(order: Order): OrderResult + + suspend fun cancelOrder(reason: String) // Signal handler + + suspend fun addItem(item: OrderItem): Boolean // Update handler + + @UpdateValidatorMethod(updateMethod = "addItem") + fun validateAddItem(item: OrderItem) + + // Queries - always synchronous, can use property syntax + val status: OrderStatus + + fun getItemCount(): Int +} + +// Use annotations only when customizing handler names +@WorkflowInterface +interface CustomNameOrderWorkflow { + @WorkflowMethod(name = "ProcessOrder") + suspend fun processOrder(order: Order): OrderResult + + @SignalMethod(name = "cancel-order") + suspend fun cancelOrder(reason: String) + + @UpdateMethod(name = "add-item") + suspend fun addItem(item: OrderItem): Boolean + + @QueryMethod(name = "get-status") + val status: OrderStatus +} +``` + +## Dynamic Handler Registration + +For workflows that need to handle signals, queries, or updates dynamically (without annotations), use the registration APIs. + +### KEncodedValues + +All dynamic handlers receive arguments as `KEncodedValues`, a Kotlin-idiomatic wrapper around the Java SDK's `EncodedValues`: + +```kotlin +class KEncodedValues(private val delegate: EncodedValues) { + val size: Int + fun isEmpty(): Boolean + + // Primary API - reified generics + inline fun get(index: Int = 0): T + inline fun get(index: Int, genericType: Type): T + + // KClass-based access + fun get(index: Int, type: KClass): T + + // Destructuring support + inline operator fun component1(): T + inline operator fun component2(): T + inline operator fun component3(): T + + // Java interop + fun toEncodedValues(): EncodedValues +} +``` + +### Dynamic Handler Examples + +```kotlin +class DynamicWorkflowImpl : DynamicWorkflow { + private var state = mutableMapOf() + + override suspend fun execute(input: String): String { + // Register a named update handler with validator + KWorkflow.registerUpdateHandler( + "updateState", + validator = { args: KEncodedValues -> + val key: String = args.get(0) + require(key.isNotBlank()) { "Key cannot be blank" } + }, + handler = { args: KEncodedValues -> + val key: String = args.get(0) + val value: Any = args.get(1) + state[key] = value + "Updated $key" + } + ) + + // Register a named signal handler + KWorkflow.registerSignalHandler("notify") { args: KEncodedValues -> + val message: String = args.get() // index defaults to 0 + println("Received: $message") + } + + // Register a named query handler + KWorkflow.registerQueryHandler("getState") { args: KEncodedValues -> + val key: String = args.get() + state[key] + } + + // Register dynamic handlers for unknown names (catch-all) + KWorkflow.registerDynamicUpdateHandler { updateName, args -> + "Handled unknown update: $updateName" + } + + KWorkflow.registerDynamicSignalHandler { signalName, args -> + println("Unknown signal: $signalName") + } + + // Dynamic query handler - 2 parameters (name and args) + KWorkflow.registerDynamicQueryHandler { queryName, args -> + "Unknown query: $queryName" + } + + // Validate all unknown updates + KWorkflow.registerDynamicUpdateValidator { updateName, args -> + require(updateName.startsWith("custom_")) { "Unknown update: $updateName" } + } + + // Wait for completion signal + KWorkflow.awaitCondition { state["done"] == true } + return "Completed" + } +} +``` + +### Using Destructuring + +```kotlin +KWorkflow.registerDynamicUpdateHandler { updateName, args -> + // Destructuring for multiple arguments + val (name: String, value: Int) = args + update(name, value) + "Updated" +} +``` + +### Dynamic Handler API Reference + +```kotlin +// Named handlers with KEncodedValues +fun registerSignalHandler(signalName: String, handler: suspend (KEncodedValues) -> Unit) +fun registerQueryHandler(queryName: String, handler: (KEncodedValues) -> Any?) +fun registerUpdateHandler(updateName: String, handler: suspend (KEncodedValues) -> Any?) +fun registerUpdateHandler( + updateName: String, + validator: (KEncodedValues) -> Unit, + handler: suspend (KEncodedValues) -> Any? +) + +// Catch-all dynamic handlers +fun registerDynamicSignalHandler(handler: suspend (signalName: String, args: KEncodedValues) -> Unit) +fun registerDynamicQueryHandler(handler: (queryName: String, args: KEncodedValues) -> Any?) +fun registerDynamicUpdateHandler(handler: suspend (updateName: String, args: KEncodedValues) -> Any?) +fun registerDynamicUpdateValidator(validator: (updateName: String, args: KEncodedValues) -> Unit) +``` + +## Client-Side Interaction + +### Sending Signals + +```kotlin +val handle = client.workflowHandle("order-123") + +// Type-safe signal using method reference +handle.signal(OrderWorkflow::cancelOrder, "Customer request") +``` + +### Querying Workflows + +```kotlin +val handle = client.workflowHandle("order-123") + +// Query using property reference +val status = handle.query(OrderWorkflow::status) + +// Query using method reference +val count = handle.query(OrderWorkflow::getItemCount) +``` + +### Executing Updates + +```kotlin +val handle = client.workflowHandle("order-123") + +// Execute update and wait for result +val added = handle.executeUpdate(OrderWorkflow::addItem, newItem) +``` + +## Update Validation + +Update validators run synchronously before the update handler. They can reject updates by throwing exceptions: + +```kotlin +// Validator annotation specifies which update method it validates +@UpdateValidatorMethod(updateMethod = "addItem") +fun validateAddItem(item: OrderItem) { + require(item.quantity > 0) { "Quantity must be positive" } + require(item.price >= BigDecimal.ZERO) { "Price cannot be negative" } + require(_status == OrderStatus.PENDING) { "Cannot add items after processing started" } +} + +// The update handler - @UpdateMethod is optional unless customizing the name +suspend fun addItem(item: OrderItem): Boolean { + // Validator already passed - safe to proceed + _items.add(item) + return true +} +``` + +## Related + +- [Client API](../client/workflow-handle.md) - More on workflow handles +- [Workflow Definition](./definition.md) - Basic workflow patterns + +--- + +**Next:** [Child Workflows](./child-workflows.md) diff --git a/kotlin/workflows/timers-parallel.md b/kotlin/workflows/timers-parallel.md new file mode 100644 index 0000000..b81812d --- /dev/null +++ b/kotlin/workflows/timers-parallel.md @@ -0,0 +1,125 @@ +# Timers and Parallel Execution + +## Timers and Delays + +```kotlin +override suspend fun workflowWithTimer(): String { + // Simple delay using Kotlin Duration + delay(5.minutes) + + return "completed" +} +``` + +The standard `kotlinx.coroutines.delay()` is intercepted by Temporal's deterministic dispatcher and creates durable timers. + +## Parallel Execution + +Use standard `coroutineScope { async { } }` for parallel execution: + +```kotlin +val options = KActivityOptions(startToCloseTimeout = 30.seconds) + +override suspend fun parallelWorkflow(items: List): List = coroutineScope { + // Process all items in parallel using standard Kotlin patterns + items.map { item -> + async { + KWorkflow.executeActivity(ProcessingActivities::process, options, item) + } + }.awaitAll() // Standard kotlinx.coroutines.awaitAll +} + +// Another example: parallel activities with different results +override suspend fun getGreetings(name: String): String = coroutineScope { + val hello = async { KWorkflow.executeActivity(GreetingActivities::greet, options, "Hello", name) } + val goodbye = async { KWorkflow.executeActivity(GreetingActivities::greet, options, "Goodbye", name) } + + // Standard awaitAll works with any Deferred + val (helloResult, goodbyeResult) = awaitAll(hello, goodbye) + "$helloResult\n$goodbyeResult" +} +``` + +**Why this works deterministically:** +- All coroutines inherit the workflow's deterministic dispatcher +- The dispatcher executes tasks in a FIFO queue ensuring consistent ordering +- Same execution order during replay + +> **Note:** `coroutineScope` provides structured concurrency—if one `async` fails, all others are automatically cancelled. This maps naturally to Temporal's cancellation semantics. + +## Await Condition + +Wait for a condition to become true (equivalent to Java's `Workflow.await()`): + +```kotlin +override suspend fun workflowWithCondition(): String { + var approved = false + + // Signal handler sets approved = true + // ... + + // Wait until approved (blocks workflow until condition is true) + KWorkflow.awaitCondition { approved } + + return "Approved" +} + +// With timeout - returns false if timed out +override suspend fun workflowWithTimeout(): String { + var approved = false + + val wasApproved = KWorkflow.awaitCondition(timeout = 24.hours) { approved } + + return if (wasApproved) "Approved" else "Timed out" +} +``` + +## Timeout Patterns + +Use `withTimeout` to cancel a block after a duration: + +```kotlin +override suspend fun processWithDeadline(order: Order): OrderResult { + return withTimeout(1.hours) { + // Everything in this block is cancelled if it takes > 1 hour + val validated = KWorkflow.executeActivity(...) + val charged = KWorkflow.executeActivity(...) + OrderResult(success = true) + } +} + +// Or use withTimeoutOrNull to get null instead of exception +override suspend fun tryProcess(order: Order): OrderResult? { + return withTimeoutOrNull(30.minutes) { + KWorkflow.executeActivity(...) + } +} +``` + +## Racing Patterns + +Race multiple operations and take the first result: + +```kotlin +override suspend fun raceOperations(): String = coroutineScope { + // Start multiple operations + val fast = async { KWorkflow.executeActivity(Activities::fastOperation, options) } + val slow = async { KWorkflow.executeActivity(Activities::slowOperation, options) } + + // Use select to get first result + select { + fast.onAwait { "Fast: $it" } + slow.onAwait { "Slow: $it" } + } + // Note: The other coroutine is still running but will be cancelled when scope exits +} +``` + +## Related + +- [Child Workflows](./child-workflows.md) - Parallel child workflow patterns +- [Workflow Definition](./definition.md) - Basic workflow patterns + +--- + +**Next:** [Cancellation](./cancellation.md) diff --git a/nexus/images/behavior.png b/nexus/images/behavior.png index 901f73d..2bd1679 100644 Binary files a/nexus/images/behavior.png and b/nexus/images/behavior.png differ diff --git a/nexus/images/full-client-id-flow.png b/nexus/images/full-client-id-flow.png index 33e3b27..3200e80 100644 Binary files a/nexus/images/full-client-id-flow.png and b/nexus/images/full-client-id-flow.png differ diff --git a/nexus/images/lost-client-id.png b/nexus/images/lost-client-id.png index ad987f1..73750d7 100644 Binary files a/nexus/images/lost-client-id.png and b/nexus/images/lost-client-id.png differ diff --git a/nexus/images/nexus-flow.png b/nexus/images/nexus-flow.png index 938a198..fb3e5ad 100644 Binary files a/nexus/images/nexus-flow.png and b/nexus/images/nexus-flow.png differ diff --git a/tasks/pr-review-answers.md b/tasks/pr-review-answers.md new file mode 100644 index 0000000..5f89bce --- /dev/null +++ b/tasks/pr-review-answers.md @@ -0,0 +1,321 @@ +# PR Review Answers + +Decisions on unanswered comments from @cretz on PR #104. + +--- + +## 1. Workflow timer/sleep (api-parity.md:11) + +**Decision:** Add `KWorkflow.delay()` with an overload for options + +```kotlin +// Simple case +KWorkflow.delay(10.seconds) + +// With options +KWorkflow.delay(10.seconds, KTimerOptions(summary = "Waiting for retry")) +``` + +**Open question:** Should stdlib `delay()` also work (mapping internally), or require explicit `KWorkflow.delay()`? + +--- + +## 2. Async await helpers (api-parity.md:20) + +**Decision:** Use standard Kotlin coroutines (Option A) - no need for `KWorkflow.awaitAll()` etc. + +```kotlin +// Use standard coroutines +coroutineScope { + val results = listOf( + async { activity1() }, + async { activity2() } + ).awaitAll() +} +``` + +--- + +## 3. Default activity options (api-parity.md:21) + +**Decision:** Per-call only (Option A) - like newer SDKs, no shared/default activity options mechanism + +--- + +## 4. Rename typedSearchAttributes (api-parity.md:70) + +**Decision:** Rename to `searchAttributes` (Option A) - no legacy to disambiguate from in Kotlin SDK + +--- + +## 5. getVersion side-effecting (api-parity.md:94) + +**Decision:** PENDING - Ask cretz if full patching API (Option D) is preferred + +--- + +## 6. WithStartWorkflowOperation design (advanced.md:21) + +**Decision:** PENDING - Present Option B and ask cretz if this is what he meant: + +```kotlin +val result = client.updateWithStart( + KWithStartWorkflowOperation(MyWorkflow::run, arg, options), // arg before options + MyWorkflow::myUpdate, + updateArg +) +``` + +--- + +## 7. Handle naming (workflow-client.md:77) + +**Decision:** Rename to `workflowHandle()` (Option C) - more Kotlin idiomatic, no get/new prefix + +--- + +## 8. Reified R for optional approach (workflow-client.md:77) + +**Decision:** Add overload to specify `R` when getting handle (Option B): + +```kotlin +val handle = client.workflowHandle(workflowId) +val result = handle.result() // R already known +``` + +--- + +## 9. Options after args (workflow-client.md:31) + +**Decision:** Already updated - args before options (Option B) in open-questions.md + +--- + +## 10. Client connect static call (workflow-client.md:12) + +**Decision:** Single `KWorkflowClient.connect(options)` like newer SDKs (Option A) + +```kotlin +val client = KWorkflowClient.connect( + KWorkflowClientOptions( + target = "localhost:7233", + namespace = "default" + ) +) + +// Access raw gRPC services when needed +val workflowService = client.workflowService +``` + +--- + +## 11. Handle type hierarchy (workflow-handle.md:45) + +**Decision:** Use cretz's recommended naming (Option B) + +```kotlin +KWorkflowHandleUntyped // Untyped base +KWorkflowHandle : KWorkflowHandleUntyped // Typed workflow, untyped result +KWorkflowHandleWithResult : KWorkflowHandle // Fully typed +``` + +--- + +## 12. Options class for handle methods (workflow-handle.md:87) + +**Decision:** Use options class with convenience overloads (Option B). Options always last. + +```kotlin +// Primary API with options +suspend fun startUpdate( + method: KSuspendFunction1, + options: KStartUpdateOptions +): KUpdateHandle + +// Convenience overload with arg before options +suspend fun startUpdate( + method: KSuspendFunction2, + arg: A1, + options: KStartUpdateOptions +): KUpdateHandle +``` + +--- + +## 13. List super class (workflow-handle.md:178) + +**Decision:** Add hierarchy (Option A) + +```kotlin +// Base class returned by listWorkflows() +open class KWorkflowExecutionInfo( + val execution: WorkflowExecution, + val workflowType: String, + val taskQueue: String, + val startTime: Instant, + val status: WorkflowExecutionStatus + // ... lighter set of fields +) + +// Extended class returned by describe() +class KWorkflowExecutionDescription( + // ... all fields from info plus additional details +) : KWorkflowExecutionInfo(...) +``` + +--- + +## 14. Wait for stage required (workflow-handle.md:86) + +**Decision:** Make waitForStage required, no default (Option B) + +```kotlin +// waitForStage is required - no default value +suspend fun startUpdate( + method: KSuspendFunction1, + options: KStartUpdateOptions // waitForStage required in options +): KUpdateHandle +``` + +Note: This aligns with the decision in Q12 to use options classes. `KStartUpdateOptions.waitForStage` will be required. + +--- + +## 15. Data converter design (README.md:52) + +**Decision:** TBD - Mark for later detailed design + +Options to consider: +- A) Newer SDK pattern with composable components (payloadConverter, failureConverter, payloadCodec) +- B) Keep current Java-style DataConverter interface + +--- + +## 16. Client interceptors (interceptors.md:1) + +**Decision:** Add client interceptors (Option A) + +Add `KWorkflowClientInterceptor` for intercepting client-side operations: +- startWorkflow +- signalWorkflow +- queryWorkflow +- executeUpdate +- etc. + +--- + +## 17. Remove required interfaces (kotlin-idioms.md:21) + +**Decision:** Already addressed - interfaces are optional (Option B already in API spec) + +Plain classes with annotated methods are supported; @WorkflowInterface/@ActivityInterface are optional. + +--- + +## 18. Cancellation statement unclear (cancellation.md:32) + +**Decision:** Clarify wording (Option A) + +Update docs to clarify this is standard Kotlin `coroutineScope` exception propagation behavior: +- When one child fails with exception, coroutineScope cancels all other children +- This is automatic Kotlin structured concurrency, not Temporal-specific behavior + +--- + +## 19. Discourage start pattern (README.md:32) + +**Decision:** Recommend run() pattern (Option A) + +Update examples to use `factory.run()` which blocks and propagates fatal errors. +Mention `start()`/`shutdown()` as alternative for advanced use cases. + +```kotlin +// Recommended pattern +factory.run() // Blocks until shutdown, propagates fatal errors + +// Alternative for advanced use cases +factory.start() // Returns immediately +// ... +factory.shutdown() +``` + +--- + +## 24. Event loop control (kotlin-idioms.md:58) + +**Decision:** Kotlin wraps Java SDK, `awaitCondition` built on `Async.await()` + +Kotlin SDK does not have its own event loop - it's implemented on top of Java SDK. The `awaitCondition` functionality will be built on top of the new `Async.await()` method being added to Java SDK (PR #2751), which returns a `Promise` for non-blocking condition waiting. + +```kotlin +// Kotlin awaitCondition suspends the coroutine +KWorkflow.awaitCondition { condition } + +// Implemented using Java's Async.await() under the hood +// Async.await(supplier) -> Promise +``` + +--- + +## 23. Priority for local activities (local-activities.md) + +**Decision:** Already addressed - priority removed from KLocalActivityOptions + +The `KLocalActivityOptions` class doesn't include `priority` or other non-applicable fields (like task queue). Local activities run in-process, so task queue routing options don't apply. + +--- + +## 22. Heartbeat details collection (implementation.md) + +**Decision:** Support single argument only, like Java SDK + +Keep it simple - heartbeat accepts a single detail value. If users need multiple values, they wrap them in a data class. + +```kotlin +// Heartbeat with single value +ctx.heartbeat(progressPercent) + +// Or wrap multiple values in a data class +data class MyProgress(val percent: Int, val checkpoint: String) +ctx.heartbeat(MyProgress(50, "step-3")) + +// Retrieve on retry +val progress: MyProgress? = ctx.lastHeartbeatDetails() +``` + +Use `lastHeartbeatDetails` naming to clarify it's from the last heartbeat before retry. + +--- + +## 21. Logger on context (implementation.md) + +**Decision:** Remove logger from context, use MDC only (Option A) + +Use MDC/coroutine context for activity and workflow metadata. Users use standard logging frameworks. Temporal SDK populates MDC automatically with workflowId, activityId, etc. + +```kotlin +// SDK sets up MDC with activity/workflow info automatically +val logger = LoggerFactory.getLogger(MyActivity::class.java) +logger.info("Processing...") // MDC includes activityId, workflowId, taskQueue, etc. +``` + +Remove `KActivity.logger()` and `KWorkflow.logger()` from the API. This matches Java SDK approach. + +--- + +## 20. Context data type (implementation.md) + +**Decision:** Use `KActivityContext.current()` pattern, remove KActivity class + +Move context access to the context class itself, matching .NET pattern. Since Kotlin doesn't have checked exceptions, `Activity.wrap()` is not needed, so `KActivity` class can be removed entirely. + +```kotlin +val ctx = KActivityContext.current() +ctx.info // ActivityInfo +ctx.heartbeat(details) +ctx.heartbeatDetails() +ctx.doNotCompleteOnReturn() +ctx.cancellationFuture() // for non-suspend activities +``` + +--- diff --git a/tasks/pr-review-questions.md b/tasks/pr-review-questions.md new file mode 100644 index 0000000..43bae29 --- /dev/null +++ b/tasks/pr-review-questions.md @@ -0,0 +1,202 @@ +# PR Review Questions to Address + +Unanswered comments from @cretz on PR #104. + +--- + +## API Parity + +### 1. Workflow timer/sleep (api-parity.md:11) +> We thought this way too about async helpers when we were developing Python, .NET, and Ruby. But there are two reasons why you may want to have a workflow version: 1) because you have more options (e.g. timer summary), and 2) because internal lang runtime developers don't know it's harmful to add an extra async. + +**Status:** [ ] TODO + +--- + +### 2. Async await helpers (api-parity.md:20) +> Technically none of these are needed in Java either and can be written with wait conditions, but we have them anyways in Java (but not usually in other languages) + +**Status:** [ ] TODO + +--- + +### 3. Default activity options (api-parity.md:21) +> Technically we could have made everyone define a shared default options in Java too, but we chose not to (for other languages we don't have this usually) + +**Status:** [ ] TODO + +--- + +### 4. Rename typedSearchAttributes (api-parity.md:70) +> I would not call this `typedSearchAttributes`, just `searchAttributes` is fine, we only used the `typed` prefix to disambiguate from the existing, deprecated form in Java. + +**Status:** [ ] TODO + +--- + +### 5. getVersion side-effecting (api-parity.md:94) +> Arguably this should have never been a getter since it is side-effecting. But I understand if we don't want to change to full-blown patching API. + +**Status:** [ ] TODO + +--- + +## Client + +### 6. WithStartWorkflowOperation design (advanced.md:21) +> Why do I need a client to create a with start workflow operation? And why does that return a "handle"? I think after lots of time spent designing this for update with start in Java, we concluded that the with-start-workflow-operation "promise" was just something passed in to `updateWithStart` (and `signalWithStart` if/when modernized). + +**Status:** [ ] TODO + +--- + +### 7. Handle naming (workflow-client.md:77) +> Would totally be ok calling these `newWorkflowHandle` or just `workflowHandle` + +**Status:** [ ] TODO + +--- + +### 8. Reified R for optional approach (workflow-client.md:77) +> Arguably one should be able to provide reified `R` too if such an optional approach is allowed, but meh + +**Status:** [ ] TODO + +--- + +### 9. Options after args (workflow-client.md:31) +> Same comments as activity concerning options after args, 0 or 1 arg overload only (maybe), etc + +**Status:** [ ] TODO + +--- + +### 10. Client connect static call (workflow-client.md:12) +> In newer SDKs, we found _requiring_ these separate steps unnecessary. Would recommend a static call on `KWorkflowClient` called `connect` that accepts options with target, namespace, etc. + +**Status:** [ ] TODO + +--- + +### 11. Handle type hierarchy (workflow-handle.md:45) +> Mentioned before, but it is confusing to have: +> * `KWorkflowHandle` - half-typed (just not result) +> * `KTypedWorkflowHandle` - typed with result +> * `WorkflowHandle` - untyped, named as if it'll be used from Java +> +> I recommend: +> * `KWorkflowHandle : KWorkflowHandleUntyped` (extends only needed if reasonable) +> * `KWorkflowHandleWithResult : KWorkflowHandle` (extends only needed if reasonable) +> * `KWorkflowHandleUntyped` + +**Status:** [ ] TODO + +--- + +### 12. Options class for handle methods (workflow-handle.md:87) +> These should be in an options class if we want to be consistent (we can sugar out to these if needed) + +**Status:** [ ] TODO + +--- + +### 13. List super class (workflow-handle.md:178) +> I assume there's a super class for this that list returns? + +**Status:** [ ] TODO + +--- + +### 14. Wait for stage required (workflow-handle.md:86) +> We very intentionally decided to _require_ wait for stage at this time because we expect to support "admitted" as a wait for stage one day and we're not sure if that will become the default or if we'll ever have a default. + +**Status:** [ ] TODO + +--- + +## Configuration + +### 15. Data converter design (README.md:52) +> Kotlin has an opportunity to improve on Java here if we want to do what newer SDKs do and make data converter a class that just accepts payload converter, failure converter, and codec instead of having it be something users implement as a whole. But ok if we don't want to have that design here. + +**Status:** [ ] TODO + +--- + +### 16. Client interceptors (interceptors.md:1) +> No client interceptors? + +**Status:** [ ] TODO + +--- + +## Workflows + +### 17. Remove required interfaces (kotlin-idioms.md:21) +> Same as activity, I think we can discuss removing the _required_ interfaces altogether (but still supporting them) + +**Status:** [ ] TODO + +--- + +### 18. Cancellation statement unclear (cancellation.md:32) +> I don't understand this statement exactly. Does it mean "if either activity fails, the workflow fails which implicitly cancels any activities" or is there something more explicit here? + +**Status:** [ ] TODO + +--- + +## Worker + +### 19. Discourage start pattern (README.md:32) +> We should discourage the `start` pattern IMO in favor of a `run` one. We have learned that `start`+`shutdown` can swallow fatal worker-runtime errors that we'd rather propagate. + +**Status:** [ ] TODO + +--- + +## Activities - Additional + +### 20. Context data type (implementation.md) +**Comment ID:** 2682791531 +> What is the data type of this context? Arguably for OO languages where the context is a type one can pass around and reference, obtaining the current context is a static method on the type itself, but if it is the Java SDK context, makes sense (static extension methods are not a thing). EDIT: I see later this is a Kotlin context class, I would recommend moving this call to that class, though it can be here too, meh. + +**Status:** [ ] TODO + +--- + +### 21. Logger on context (implementation.md) +**Comment ID:** 2682832357 +> Can I get more detail on why a logger would be on the context. Is there an expected Kotlin logging solution that expects situationally stateful loggers instead of existing MDC/NDC thread/coroutine-local types of approaches? + +**Status:** [ ] TODO + +--- + +### 22. Heartbeat details collection (implementation.md) +**Comment ID:** 2682834623 +> Heartbeat details is a collection, this will either have to accept an index and have a total-count method, or maybe accept some kind of reified tuple type or something. I guess there can be a helper for just the first detail item. Same for the actual `heartbeat` call. +> +> Also, will there be a `lastHeartbeatDetails`? + +**Status:** [ ] TODO + +--- + +### 23. Priority for local activities (local-activities.md) +**Comment ID:** 2682941947 +> Priority doesn't make sense here + +**Status:** [ ] TODO + +--- + +## Workflows - Additional + +### 24. Event loop control (kotlin-idioms.md:58) +**Comment ID:** 2683150870 +> Do we have full control over the event loop including running the `awaitCondition` stuff when/how we want in that loop, or is this somehow just sugar on top of the Java one (sorry, could dig into the PoC, but being lazy) + +**Status:** [ ] TODO + +--- diff --git a/tasks/pr-review-round2.md b/tasks/pr-review-round2.md new file mode 100644 index 0000000..96f15fe --- /dev/null +++ b/tasks/pr-review-round2.md @@ -0,0 +1,197 @@ +# PR Review Round 2 - cretz Comments + +## Q1: Default Parameters - Other SDKs Support Them +**Comment ID:** 2687176966 +**File:** kotlin/open-questions.md:35 +**Status:** DECIDED + +> While I agree with the decision to disallow because it can be easily walked back if we change our minds, it may be worth noting that IIRC, Python, .NET, and Ruby all support default parameters. Here is a Python test confirming it: https://github.com/temporalio/sdk-python/blob/993de6d0e9b42bb01f24edfdb46e0795b00debcf/tests/worker/test_workflow.py#L3502-L3545. + +**Decision:** Allow default parameters for the 0-1 argument case, aligning with Python/.NET/Ruby. With the 0-1 parameter model (>1 falls back to untyped), Kotlin's out-of-order named parameter complexity becomes moot. + +**Response:** Good point about other SDKs supporting defaults. Given we're moving to 0-1 parameters (with >1 falling back to untyped), supporting a default for a single parameter is straightforward and aligns with Python/.NET/Ruby. Will update the docs to allow defaults for the single-parameter case. + +--- + +## Q2: Interfaceless - Require Matching Annotations on Overrides +**Comment ID:** 2687205136 +**File:** kotlin/open-questions.md:162 +**Status:** DECIDED + +> Note, in order to support this in some newer SDKs and not hit diamond problems or other inheritance confusion for when they _do_ want to have interfaces/abstracts, we _require_ every overridden method to have the same matching annotation/attribute/decorator. This prevents ambiguity, is easy to understand, and clarifies author intent clearly to reader without relying on inheritance (at the small cost of having to write duplicate annotations on overrides). + +**Decision:** Keep current Java SDK behavior (annotation only on interface, not required on implementation). Requiring duplicate annotations would break backward compatibility with existing Java activities/workflows and feel unnatural to Java SDK users. + +**Response:** While this approach makes sense for newer SDKs starting fresh, requiring duplicate annotations would break backward compatibility with existing Java activities/workflows and feel unnatural to Java SDK users. Since Kotlin SDK builds on Java SDK, we'll maintain the current convention where annotations on the interface are sufficient. + +--- + +## Q3: Type-Safe Args - 0-1 Arguments Pattern +**Comment ID:** 2687220447 +**File:** kotlin/open-questions.md:216 +**Status:** DECIDED + +> Or "0-1 Argument(s)", meaning any > 1 argument, which Temporal discourages, can fall back to untyped. This is what we do in some newer SDKs. + +**Our Follow-up:** Why do you prefer the type-unsafe fallback for >1 args instead of the KArgs approach which maintains type safety? Is API simplicity the main driver, or are there other considerations? + +**cretz Response (2690497106):** +> API simplicity (and overload count) was my main driver in preferring just falling back to untyped, but in some SDKs (namely Python), we actually do support arbitrary multi-param typed as a different overload. Would be fine if we did that here too. And really, with how .NET is lambda expressions, it supports multi-param typed as well. So overall, yeah, ok w/ a typed multi-param, and having 0 or 1 be even simpler forms of that. + +**Decision:** Use KArgs approach (Option C) - type-safe for all arities with `kargs()` wrapper for 2+ arguments. 0-1 args have simpler direct forms. + +**Response:** Thanks for confirming. We'll go with the KArgs approach (Option C) - provides full type safety for all arities while keeping 0-1 argument cases simple. The `kargs()` wrapper for 2+ args is a small price for compile-time type checking. + +--- + +## Q4: Data Classes - Binary Breaking Not Considered Breaking +**Comment ID:** 2687231550 +**File:** kotlin/open-questions.md:506 +**Status:** DEFERRED - Need to think + +> Hrmm, not sure we have ever considered binary breaking changes to be breaking changes from our POV (i.e. we expect you to compile against the same JAR you'll run with). Having said that, definitely not against better options patterns. But I don't think we want to discourage e.g. users from using `data` classes for their need-backwards-compatibility models. + +**Decision:** Deferred + +**Response:** + +--- + +## Q5: Pure Kotlin Worker Options +**Comment ID:** 2687245857 +**File:** kotlin/activities/implementation.md:68 +**In Reply To:** 2682769025 +**Status:** DECIDED + +> I saw later there is a concept of a "plugin" that you register with a worker if you're using Java interop. Note, for a pure Kotlin experience, we don't have to be subject to Java worker approaches even if it wraps a Java worker. For instance, activities, workflows, Nexus services, etc can be worker options. But I understand not wanting to deviate too far from Java. + +**Decision:** Follow Python/.NET pattern - pass workflows/activities at worker construction time via options rather than separate registration methods. + +**Response:** Agreed. Will follow the Python/.NET pattern where workflows and activities are passed at worker construction time via options. This aligns with newer SDKs and provides immutable, all-in-one-place configuration. + +--- + +## Q6: Heartbeat Infallible + Coroutine Cancellation +**Comment ID:** 2687258492 +**File:** kotlin/activities/implementation.md:1 +**In Reply To:** 2682800514 +**Status:** DECIDED + +> Not understanding "push" cancellation, but yeah so long as I can have heartbeat infallible and cancellation represented as traditional Kotlin coroutine cancellation, I think that is ideal. Arguably both of those could be the default, but I understand if it is confusing to have `suspend fun` do something completely different than non-suspend `fun` (this is a struggle we run into w/ Python where cancellation is represented quite differently w/ `async def` vs just `def`, but not this differently, heartbeat remained infallible on both). + +**Decision:** +- Heartbeat infallible (never throws) +- Suspend activities: Standard coroutine cancellation (CancellationException at suspension points) +- Non-suspend activities: `CompletableFuture` via `KActivity.cancellationFuture()` - supports polling (isDone), blocking (get), or callbacks (thenAccept) + +**Response:** Agreed on heartbeat infallible. For cancellation: +- Suspend activities: Standard Kotlin coroutine cancellation +- Non-suspend activities: `KActivity.cancellationFuture()` returns `CompletableFuture` which supports polling (`isDone`), blocking (`get`), or callback notification (`thenAccept`) - similar flexibility to Python's approach. + +--- + +## Q7: Inconsistent Annotation Usage +**Comment ID:** 2687264865 +**File:** kotlin/activities/local-activities.md:10 +**In Reply To:** 2682894879 +**Status:** DECIDED + +> Makes sense, was just a bit strange to see it present inconsistently sometimes even w/out name customization in this proposal + +**Decision:** Only show annotations in examples when customizing name (minimal approach). Will clean up docs for consistency. + +**Response:** Good catch. Will clean up the docs to be consistent - only showing annotations like `@ActivityMethod` when customizing the name. + +--- + +## Q8: Workflow-Specific Alternatives for Sleep +**Comment ID:** 2687272025 +**File:** kotlin/api-parity.md:11 +**In Reply To:** 2682956017 +**Status:** DECIDED + +> Using existing async utilities makes sense, but still may need some workflow-specific alternatives for advanced users, such as being able to provide a timer summary for `sleep`. + +**Decision:** Provide both overloads: +- `KWorkflow.delay(duration)` - simple alternative to stdlib `delay()` +- `KWorkflow.delay(duration, summary)` - for timer summaries + +**Response:** Agreed. Will provide `KWorkflow.delay(duration)` as a simple alternative to stdlib `delay()`, plus `KWorkflow.delay(duration, summary)` for advanced users who need timer summaries. + +--- + +## Q9: KotlinPlugin Naming Confusion +**Comment ID:** 2687294728 +**File:** kotlin/worker/setup.md:97 +**In Reply To:** 2683181166 +**Status:** DECIDED + +> Makes sense to implement Kotlin support on Java workers as a plugin, though may get a bit confusing to call it KotlinPlugin, assuming there will _also_ be KPlugin for users to implement plugins in Kotlin (granted I can't think of a much better name, maybe KotlinToJavaWorkerPlugin or something) + +**Decision:** Rename to `KotlinJavaWorkerPlugin` - clearly indicates it's for integrating Kotlin with Java workers. + +**Response:** Good point about naming confusion. Renamed to `KotlinJavaWorkerPlugin` to make it clear this is for integrating Kotlin coroutine support with Java workers. + +--- + +## Q10: coroutineScope vs supervisorScope +**Comment ID:** 2687311387 +**File:** kotlin/workflows/cancellation.md:32 +**In Reply To:** 2683186177 +**Status:** RESOLVED + +> Ah, I see this is the difference between `coroutineScope` and `supervisorScope` + +**Decision:** No action needed - cretz's comment is an acknowledgment that he understood the distinction. + +**Response:** (No response needed - resolved by acknowledgment) + +--- + +## Q11: Heartbeat Infallible - Slot Eating Concern +**Comment ID:** 2690451590 +**File:** kotlin/activities/implementation.md +**In Reply To:** 2682800514 (Q6) +**Status:** DECIDED + +> Note, I was a bit on the fence here of whether heartbeat being infallible should be the default. There are pros/cons to the default of heartbeat still throwing. Granted this is one of those defaults people will probably never change via worker options, so maybe deviating from Java is accepted here. But we've found if we don't interrupt these activities by default and require people to opt-in to checking cancellation (i.e. the non-suspend ones), they will eat slots because people will forget. + +**Concern:** Non-suspend activities may eat worker slots if developers forget to check cancellation. + +**Decision:** Heartbeat throws `CancellationException` on cancellation for both suspend and non-suspend activities. This prevents slot eating and provides consistent behavior. Add TODO for `cancellationFuture()` which will be needed when cancellation can be delivered without heartbeat. + +**Response:** Good point about slot eating. Revised approach: heartbeat throws `CancellationException` on cancellation for both suspend and non-suspend activities. This prevents slot eating and aligns with Java behavior. We'll document `cancellationFuture()` as a TODO for future use when cancellation can be delivered without requiring heartbeat calls. + +--- + +## Q12: Unified Client Suggestion +**Comment ID:** 2690483803 +**File:** kotlin/client/workflow-client.md +**Status:** DECIDED + +> Forgot to mention this, but in newer SDKs, we found just having one big client that is a workflow client + schedule client (w/ features to make an async activity completion handle) is a bit cleaner from an options POV. This will also help when standalone activity client and Nexus operation client come about. No need to change though if we don't want, there is also value in matching what Java does (though you will duplicate a lot of these client options each time). + +**Suggestion:** Consider unified `KClient` instead of separate `KWorkflowClient`, `KScheduleClient`, etc. + +**Decision:** Use unified `KClient` matching Python/.NET style. Single client with workflow, schedule, async activity completion, and future Nexus support. + +**Response:** Agreed. We'll use a unified `KClient` matching the Python/.NET pattern - single client covering workflows, schedules, async activity completion, and ready for future Nexus support. Cleaner options and less duplication. + +--- + +## Previous Pending Questions + +### R1-Q5: getVersion API +**Comment ID:** 2682977990 +**Status:** DECIDED + +**cretz Response (2690460783):** +> I have no strong preference, but as a past Kotlin developer, I always thought in terms of Java, so I think our default stance of "be like Java" is a good one for developer understanding. (I'm not even sure the newer patching approaches for Core-based SDKs can be done in Java user-land today without Java SDK updates) + +**Decision:** Use `KWorkflow.version(changeId, minVersion, maxVersion)` method instead of getter-style. Better indicates side-effect nature. + +**Response:** Since there's no strong preference, we'll use `KWorkflow.version()` as a method rather than getter-style. This better indicates the side-effecting nature of the call, which feels more idiomatic for Kotlin. + +### R1-Q6: WithStartWorkflowOperation Design +**Comment ID:** 2683006111 +**Status:** PENDING - Awaiting cretz clarification