diff --git a/Examples/AsyncActivities/AsyncActivitiesExample.swift b/Examples/AsyncActivities/AsyncActivitiesExample.swift new file mode 100644 index 0000000..97d15cc --- /dev/null +++ b/Examples/AsyncActivities/AsyncActivitiesExample.swift @@ -0,0 +1,241 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Temporal SDK open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift Temporal SDK project authors +// Licensed under MIT License +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Temporal SDK project authors +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +#if GRPCNIOTransport +import Foundation +import GRPCNIOTransportHTTP2Posix +import Logging +import Temporal + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Async Activities Example - NYC Film Permit Processing. +/// +/// This example demonstrates parallel/concurrent activity execution patterns in Temporal: +/// +/// - **Parallel Activity Execution**: Using `async let` to run multiple activities concurrently +/// - **Task Groups**: Processing multiple permits in parallel with `withThrowingTaskGroup` +/// - **Multiple Workers**: Running 5 workers simultaneously to distribute activity load +/// - **External API Integration**: Fetching data from NYC Open Data API with retry policies +/// - **Performance Comparison**: Sequential vs parallel processing with timing metrics +/// +/// The example uses the NYC Film Permits API to demonstrate a data processing pipeline where:. +/// - Each permit undergoes multiple analysis steps (validation, location, categorization) +/// - Multiple permits are processed concurrently across workers +/// - Activities are distributed across worker instances for parallel execution +@main +struct AsyncActivitiesExample { + /// Fetch permits from NYC API outside of workflow timing. + static func fetchPermits(count: Int) async throws -> [FilmPermitActivities.FilmPermit] { + let url = URL(string: "https://data.cityofnewyork.us/resource/tg4x-b46p.json?$limit=\(count)")! + var request = URLRequest(url: url) + request.timeoutInterval = 30 + request.httpMethod = "GET" + + let (data, _) = try await URLSession.shared.data(for: request) + let decoder = JSONDecoder() + return try decoder.decode([FilmPermitActivities.FilmPermit].self, from: data) + } + + static func main() async throws { + var logger = Logger(label: "TemporalWorker") + logger.logLevel = .info + + let namespace = "default" + let taskQueue = "film-permit-queue" + + print("🎬 NYC Film Permit Processing - Async Activities Example") + print(String(repeating: "=", count: 70)) + print() + + // Create worker configuration + let workerConfiguration = TemporalWorker.Configuration( + namespace: namespace, + taskQueue: taskQueue, + instrumentation: .init(serverHostname: "localhost") + ) + + // Create activities + let activities = FilmPermitActivities() + + // Helper to create a worker + func createWorker(workerId: Int) throws -> TemporalWorker { + var workerLogger = Logger(label: "TemporalWorker-\(workerId)") + workerLogger.logLevel = .info + + return try TemporalWorker( + configuration: workerConfiguration, + target: .ipv4(address: "127.0.0.1", port: 7233), + transportSecurity: .plaintext, + activityContainers: activities, + activities: [], + workflows: [FilmPermitWorkflow.self], // All workers can handle workflows + logger: workerLogger + ) + } + + // Create client + let client = try TemporalClient( + target: .ipv4(address: "127.0.0.1", port: 7233), + transportSecurity: .plaintext, + configuration: .init( + instrumentation: .init( + serverHostname: "localhost" + ) + ), + logger: logger + ) + + try await withThrowingTaskGroup(of: Void.self) { group in + // Start 5 workers + print("🚀 Starting Workers:") + for workerId in 1...5 { + let worker = try createWorker(workerId: workerId) + group.addTask { + print(" ✅ Worker \(workerId) started (PID: \(ProcessInfo.processInfo.processIdentifier))") + try await worker.run() + } + } + + // Start client + group.addTask { + try await client.run() + } + + // Wait for worker and client to initialize + try await Task.sleep(for: .seconds(2)) + + print() + print(String(repeating: "=", count: 70)) + print() + + // Fetch permits once, outside of workflow timing + print("📥 Fetching film permits from NYC API...") + let permits = try await fetchPermits(count: 100) // Fetch large sample + print("✅ Fetched \(permits.count) permits") + print() + + print(String(repeating: "=", count: 70)) + print() + + // Run sequential processing first + print("⏳ Test 1: Sequential Processing") + print(String(repeating: "-", count: 70)) + let sequentialWorkflowId = "PERMITS-SEQ-\(UUID().uuidString.prefix(8))" + let sequentialRequest = FilmPermitWorkflow.BatchRequest( + permits: permits, + mode: .sequential + ) + + print("🔗 View in Temporal UI:") + print(" http://localhost:8233/namespaces/\(namespace)/workflows/\(sequentialWorkflowId)") + print() + + let sequentialStart = Date() + let sequentialResult = try await client.executeWorkflow( + type: FilmPermitWorkflow.self, + options: .init(id: sequentialWorkflowId, taskQueue: taskQueue), + input: sequentialRequest + ) + let sequentialDuration = Date().timeIntervalSince(sequentialStart) + + print() + print("✅ Sequential Processing Complete:") + print(" Total permits: \(sequentialResult.report.totalPermits)") + print(" Valid permits: \(sequentialResult.report.validPermits)") + print(" Total time: \(String(format: "%.2f", sequentialDuration))s") + print(" Average per permit: \(String(format: "%.2f", sequentialDuration / Double(sequentialResult.report.totalPermits)))s") + print() + + // Display borough breakdown + if !sequentialResult.report.byBorough.isEmpty { + print(" By Borough:") + for (borough, count) in sequentialResult.report.byBorough.sorted(by: { $0.value > $1.value }) { + print(" • \(borough): \(count) permits") + } + print() + } + + // Run parallel processing + print(String(repeating: "=", count: 70)) + print() + print("⚡ Test 2: Parallel Processing") + print(String(repeating: "-", count: 70)) + let parallelWorkflowId = "PERMITS-PAR-\(UUID().uuidString.prefix(8))" + let parallelRequest = FilmPermitWorkflow.BatchRequest( + permits: permits, + mode: .parallel + ) + + print("🔗 View in Temporal UI:") + print(" http://localhost:8233/namespaces/\(namespace)/workflows/\(parallelWorkflowId)") + print() + print("📊 Processing \(permits.count) permits in parallel...") + print() + + let parallelStart = Date() + let parallelResult = try await client.executeWorkflow( + type: FilmPermitWorkflow.self, + options: .init(id: parallelWorkflowId, taskQueue: taskQueue), + input: parallelRequest + ) + let parallelDuration = Date().timeIntervalSince(parallelStart) + + print() + print("✅ Parallel Processing Complete:") + print(" Total permits: \(parallelResult.report.totalPermits)") + print(" Valid permits: \(parallelResult.report.validPermits)") + print(" Total time: \(String(format: "%.2f", parallelDuration))s") + print(" Average per permit: \(String(format: "%.2f", parallelDuration / Double(parallelResult.report.totalPermits)))s") + print() + + // Display category breakdown + if !parallelResult.report.byCategory.isEmpty { + print(" By Category:") + for (category, count) in parallelResult.report.byCategory.sorted(by: { $0.value > $1.value }).prefix(5) { + print(" • \(category): \(count) permits") + } + print() + } + + // Performance comparison + print(String(repeating: "=", count: 70)) + print() + print("📈 Performance Summary:") + print(String(repeating: "-", count: 70)) + print(" Sequential: \(String(format: "%.2f", sequentialDuration))s for \(permits.count) permits") + print(" Parallel: \(String(format: "%.2f", parallelDuration))s for \(permits.count) permits") + print() + let speedup = sequentialDuration / parallelDuration + print(" Speedup: \(String(format: "%.1f", speedup))x") + print(" (Parallel processing is \(String(format: "%.1f", speedup))x faster)") + print() + print("✅ Example completed successfully!") + print() + + // Cancel worker and client + group.cancelAll() + } + } +} +#else +@main +struct AsyncActivitiesExample { + static func main() async throws { + fatalError("GRPCNIOTransport trait disabled") + } +} +#endif diff --git a/Examples/AsyncActivities/FilmPermitActivities.swift b/Examples/AsyncActivities/FilmPermitActivities.swift new file mode 100644 index 0000000..814b1dd --- /dev/null +++ b/Examples/AsyncActivities/FilmPermitActivities.swift @@ -0,0 +1,252 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Temporal SDK open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift Temporal SDK project authors +// Licensed under MIT License +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Temporal SDK project authors +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import Foundation +import Temporal + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Activities for processing NYC film permits. +@ActivityContainer +public struct FilmPermitActivities { + // MARK: - Data Models + + public struct FilmPermit: Codable, Sendable { + let eventId: String + let eventType: String + let startDateTime: String + let endDateTime: String? + let eventAgency: String + let parkingHeld: String + let borough: String + let category: String + let subcategoryName: String? + let zipCode: String? + let policePrecinct: String? + + enum CodingKeys: String, CodingKey { + case eventId = "eventid" + case eventType = "eventtype" + case startDateTime = "startdatetime" + case endDateTime = "enddatetime" + case eventAgency = "eventagency" + case parkingHeld = "parkingheld" + case borough + case category + case subcategoryName = "subcategoryname" + case zipCode = "zipcode_s" + case policePrecinct = "policeprecinct_s" + } + } + + public struct ValidationResult: Codable, Sendable { + let permitId: String + let isValid: Bool + let issues: [String] + } + + public struct LocationAnalysis: Codable, Sendable { + let permitId: String + let borough: String + let precinct: String? + let zipCode: String? + let locationDescription: String + let estimatedStreetCount: Int + } + + public struct PermitCategory: Codable, Sendable { + let permitId: String + let category: String + let subcategory: String + let eventType: String + let isCommercial: Bool + } + + public struct PermitAnalysis: Codable, Sendable { + let permit: FilmPermit + let validation: ValidationResult + let location: LocationAnalysis + let category: PermitCategory + let processingTimeMs: Double + } + + public struct AnalyticsReport: Codable, Sendable { + let totalPermits: Int + let validPermits: Int + let byBorough: [String: Int] + let byCategory: [String: Int] + let topLocations: [String] + let processingTimeMs: Double + } + + // MARK: - Activities + + /// Fetches film permits from NYC Open Data API. + @Activity + func fetchFilmPermits(input: Int) async throws -> [FilmPermit] { + let context = ActivityExecutionContext.current! + let workerId = ProcessInfo.processInfo.processIdentifier + + print("📥 [Worker \(workerId)] Fetching \(input) film permits from NYC API...") + + let url = URL(string: "https://data.cityofnewyork.us/resource/tg4x-b46p.json?$limit=\(input)")! + var request = URLRequest(url: url) + request.timeoutInterval = 30 + request.httpMethod = "GET" + + do { + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw ApplicationError(message: "Invalid response type", type: "NetworkError") + } + + guard (200...299).contains(httpResponse.statusCode) else { + throw ApplicationError( + message: "HTTP \(httpResponse.statusCode)", + type: "HTTPError", + isNonRetryable: httpResponse.statusCode == 404 + ) + } + + let decoder = JSONDecoder() + let permits = try decoder.decode([FilmPermit].self, from: data) + + context.heartbeat() + print("✅ [Worker \(workerId)] Fetched \(permits.count) permits") + + return permits + + } catch let error as URLError { + throw ApplicationError( + message: "Network error: \(error.localizedDescription)", + type: "NetworkError" + ) + } catch let error as DecodingError { + throw ApplicationError( + message: "Failed to parse API response: \(error)", + type: "DecodingError" + ) + } + } + + /// Validates permit data quality. + @Activity + func validatePermit(input: FilmPermit) async throws -> ValidationResult { + let workerId = ProcessInfo.processInfo.processIdentifier + print("✓ [Worker \(workerId)] Validating permit \(input.eventId)") + + var issues: [String] = [] + + // Check required fields + if input.eventId.isEmpty { + issues.append("Missing event ID") + } + if input.borough.isEmpty { + issues.append("Missing borough") + } + if input.parkingHeld.isEmpty { + issues.append("Missing location") + } + + // Validate date format + if !input.startDateTime.contains("T") { + issues.append("Invalid date format") + } + + let isValid = issues.isEmpty + return ValidationResult( + permitId: input.eventId, + isValid: isValid, + issues: issues + ) + } + + /// Analyzes permit location details. + @Activity + func analyzeLocation(input: FilmPermit) async throws -> LocationAnalysis { + let workerId = ProcessInfo.processInfo.processIdentifier + print("📍 [Worker \(workerId)] Analyzing location for permit \(input.eventId)") + + // Count street segments (rough estimate based on "between" mentions) + let locationLower = input.parkingHeld.lowercased() + let streetCount = locationLower.components(separatedBy: "between").count - 1 + 1 + + return LocationAnalysis( + permitId: input.eventId, + borough: input.borough, + precinct: input.policePrecinct, + zipCode: input.zipCode, + locationDescription: input.parkingHeld, + estimatedStreetCount: streetCount + ) + } + + /// Categorizes permit by type. + @Activity + func categorizePermit(input: FilmPermit) async throws -> PermitCategory { + let workerId = ProcessInfo.processInfo.processIdentifier + print("🎬 [Worker \(workerId)] Categorizing permit \(input.eventId)") + + let commercialCategories = ["Commercial", "Advertisement", "Still Photography"] + let isCommercial = commercialCategories.contains(input.category) + + return PermitCategory( + permitId: input.eventId, + category: input.category, + subcategory: input.subcategoryName ?? "Unknown", + eventType: input.eventType, + isCommercial: isCommercial + ) + } + + /// Generates analytics report from permit analyses. + @Activity + func generateAnalyticsReport(input: [PermitAnalysis]) async throws -> AnalyticsReport { + let workerId = ProcessInfo.processInfo.processIdentifier + print("📊 [Worker \(workerId)] Generating analytics report from \(input.count) permits") + + let startTime = Date() + + let validPermits = input.filter { $0.validation.isValid }.count + + // Count by borough + var boroughCounts: [String: Int] = [:] + for analysis in input { + boroughCounts[analysis.permit.borough, default: 0] += 1 + } + + // Count by category + var categoryCounts: [String: Int] = [:] + for analysis in input { + categoryCounts[analysis.permit.category, default: 0] += 1 + } + + // Top locations (first 5 unique boroughs) + let topLocations = Array(Set(input.map { $0.permit.borough })).prefix(5).map { String($0) } + + let processingTime = Date().timeIntervalSince(startTime) * 1000 + + return AnalyticsReport( + totalPermits: input.count, + validPermits: validPermits, + byBorough: boroughCounts, + byCategory: categoryCounts, + topLocations: topLocations, + processingTimeMs: processingTime + ) + } +} diff --git a/Examples/AsyncActivities/FilmPermitWorkflow.swift b/Examples/AsyncActivities/FilmPermitWorkflow.swift new file mode 100644 index 0000000..547f4dc --- /dev/null +++ b/Examples/AsyncActivities/FilmPermitWorkflow.swift @@ -0,0 +1,115 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Temporal SDK open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift Temporal SDK project authors +// Licensed under MIT License +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Temporal SDK project authors +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import Foundation +import Temporal + +/// Workflow for processing NYC film permits with parallel and sequential modes. +@Workflow +public final class FilmPermitWorkflow { + public enum ProcessingMode: String, Codable, Sendable { + case sequential + case parallel + } + + public struct BatchRequest: Codable, Sendable { + let permits: [FilmPermitActivities.FilmPermit] + let mode: ProcessingMode + } + + public struct BatchResult: Codable, Sendable { + let report: FilmPermitActivities.AnalyticsReport + let processingTimeMs: Double + } + + public func run(input: BatchRequest) async throws -> BatchResult { + let startTime = Date() + + // Process permits based on mode (permits already fetched) + var analyses: [FilmPermitActivities.PermitAnalysis] = [] + + if input.mode == .sequential { + // Sequential processing + for permit in input.permits { + let analysis = try await processPermit(permit: permit) + analyses.append(analysis) + } + } else { + // Parallel processing with task group + try await withThrowingTaskGroup(of: FilmPermitActivities.PermitAnalysis.self) { group in + for permit in input.permits { + group.addTask { + try await self.processPermit(permit: permit) + } + } + + for try await analysis in group { + analyses.append(analysis) + } + } + } + + // Generate analytics report + let report = try await Workflow.executeActivity( + FilmPermitActivities.Activities.GenerateAnalyticsReport.self, + options: ActivityOptions( + startToCloseTimeout: .seconds(10) + ), + input: analyses + ) + + let totalTime = Date().timeIntervalSince(startTime) * 1000 + + return BatchResult( + report: report, + processingTimeMs: totalTime + ) + } + + /// Process a single permit through validation, location analysis, and categorization. + private func processPermit(permit: FilmPermitActivities.FilmPermit) async throws -> FilmPermitActivities.PermitAnalysis { + let permitStart = Date() + + // Run three analyses in parallel using async let + async let validation = Workflow.executeActivity( + FilmPermitActivities.Activities.ValidatePermit.self, + options: ActivityOptions(startToCloseTimeout: .seconds(5)), + input: permit + ) + + async let location = Workflow.executeActivity( + FilmPermitActivities.Activities.AnalyzeLocation.self, + options: ActivityOptions(startToCloseTimeout: .seconds(5)), + input: permit + ) + + async let category = Workflow.executeActivity( + FilmPermitActivities.Activities.CategorizePermit.self, + options: ActivityOptions(startToCloseTimeout: .seconds(5)), + input: permit + ) + + let (validationResult, locationResult, categoryResult) = try await (validation, location, category) + + let processingTime = Date().timeIntervalSince(permitStart) * 1000 + + return FilmPermitActivities.PermitAnalysis( + permit: permit, + validation: validationResult, + location: locationResult, + category: categoryResult, + processingTimeMs: processingTime + ) + } +} diff --git a/Examples/AsyncActivities/README.md b/Examples/AsyncActivities/README.md new file mode 100644 index 0000000..0232f85 --- /dev/null +++ b/Examples/AsyncActivities/README.md @@ -0,0 +1,187 @@ +# Async Activities Example - NYC Film Permit Processing + +Demonstrates parallel and concurrent activity execution in Temporal workflows using NYC's Open Data API to process film permits. + +## Features Demonstrated + +### Parallel Activity Execution +- **`async let`**: Run multiple analysis activities concurrently per permit +- **Task Groups**: Process multiple permits in parallel with `withThrowingTaskGroup` +- **Multiple Workers**: Distribute activity execution across 5 worker instances + +### External API Integration +- Fetches data from [NYC Open Data API](https://data.cityofnewyork.us/City-Government/Film-Permits/tg4x-b46p/about_data) +- HTTP requests with timeout and retry policies +- Handles network errors and API failures gracefully +- No external dependencies or submodules required + +### Performance Comparison +- Sequential vs parallel processing modes +- Timing metrics showing speedup from parallelization +- Demonstrates scalability + +## Activities + +### `fetchFilmPermits` +Fetches film permits from NYC Open Data API. +- **Input**: Number of permits to fetch +- **Output**: Array of film permit records +- **API**: `https://data.cityofnewyork.us/resource/tg4x-b46p.json` +- **Retry Policy**: 3 attempts with exponential backoff + +### `validatePermit` +Validates permit data quality. +- **Input**: Film permit record +- **Output**: Validation result with issues list +- **Checks**: Required fields, date formats, data completeness + +### `analyzeLocation` +Analyzes permit location details. +- **Input**: Film permit record +- **Output**: Location analysis (borough, precinct, street count) +- **Processing**: Parses location strings and extracts geographic data + +### `categorizePermit` +Categorizes permit by type and commercial status. +- **Input**: Film permit record +- **Output**: Category classification +- **Classification**: Film, TV, Commercial, Still Photography, etc. + +### `generateAnalyticsReport` +Generates summary report from all permit analyses. +- **Input**: Array of permit analyses +- **Output**: Analytics report with aggregated statistics +- **Metrics**: Counts by borough, category, validation status + +## Workflow Execution Patterns + +### Sequential Mode +Processes permits one at a time: +```swift +for permit in permits { + let analysis = try await processPermit(permit: permit) + analyses.append(analysis) +} +``` + +### Parallel Mode +Processes all permits concurrently using task groups: +```swift +try await withThrowingTaskGroup(of: PermitAnalysis.self) { group in + for permit in permits { + group.addTask { + try await self.processPermit(permit: permit) + } + } + // Collect results as they complete +} +``` + +### Per-Permit Concurrency +Each permit runs multiple analyses in parallel: +```swift +async let validation = Workflow.executeActivity(validatePermit, ...) +async let location = Workflow.executeActivity(analyzeLocation, ...) +async let category = Workflow.executeActivity(categorizePermit, ...) + +let (v, l, c) = try await (validation, location, category) +``` + +## Setup + +### Prerequisites +1. Temporal server running locally: +```bash +temporal server start-dev +``` + +2. Internet connection for API access (no authentication required) + +## Running the Example + +```bash +swift run AsyncActivitiesExample +``` + +This runs 5 workers that demonstrate both sequential and parallel activity execution patterns. + +### Expected Output + +``` +🎬 NYC Film Permit Processing - Async Activities Example +====================================================================== + +🚀 Starting Workers: + ✅ Worker 1 started (PID: 12345) + ✅ Worker 2 started (PID: 12345) + ✅ Worker 3 started (PID: 12345) + ✅ Worker 4 started (PID: 12345) + ✅ Worker 5 started (PID: 12345) + +====================================================================== + +📥 Fetching film permits from NYC API... +✅ Fetched 100 permits + +====================================================================== + +⏳ Test 1: Sequential Processing +---------------------------------------------------------------------- +🔗 View in Temporal UI: + http://localhost:8233/namespaces/default/workflows/PERMITS-SEQ-... + +✅ Sequential Processing Complete: + Total permits: 100 + Valid permits: 100 + Total time: 9.17s + Average per permit: 0.09s + + By Borough: + • Manhattan: 42 permits + • Brooklyn: 28 permits + • Queens: 18 permits + • Bronx: 8 permits + • Staten Island: 4 permits + +====================================================================== + +⚡ Test 2: Parallel Processing +---------------------------------------------------------------------- +🔗 View in Temporal UI: + http://localhost:8233/namespaces/default/workflows/PERMITS-PAR-... + +📊 Processing 100 permits in parallel... + +✅ Parallel Processing Complete: + Total permits: 100 + Valid permits: 100 + Total time: 0.73s + Average per permit: 0.01s + + By Category: + • Television: 38 permits + • Film: 24 permits + • WEB: 16 permits + • Commercial: 12 permits + • Still Photography: 10 permits + +====================================================================== + +📈 Performance Summary: +---------------------------------------------------------------------- + Sequential: 9.17s for 100 permits + Parallel: 0.73s for 100 permits + + Speedup: 12.6x + (Parallel processing is 12.6x faster) + +✅ Example completed successfully! +``` + +## Key Patterns + +### Multiple Workers +Worker instances share the same task queue. Temporal automatically distributes activities across available workers for parallel execution. + +### Workflow Concurrency +Leverages Swift's async/await and Structured Concurrency within workflows while maintaining Temporal's deterministic execution guarantees. See [Workflow Concurrency](../../Sources/Temporal/Documentation.docc/Workflows/Workflow-Concurrency.md) for details. diff --git a/Examples/ChildWorkflows/AssignDeliveryWorkflow.swift b/Examples/ChildWorkflows/AssignDeliveryWorkflow.swift new file mode 100644 index 0000000..6324a7d --- /dev/null +++ b/Examples/ChildWorkflows/AssignDeliveryWorkflow.swift @@ -0,0 +1,59 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Temporal SDK open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift Temporal SDK project authors +// Licensed under MIT License +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Temporal SDK project authors +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import Temporal + +/// Child workflow that handles delivery assignment and execution. +/// +/// Runs sequentially after cooking is complete. +@Workflow +final class AssignDeliveryWorkflow { + // MARK: - Input/Output Types + + struct DeliveryInput: Codable { + let orderId: String + let address: String + let phone: String + let itemCount: Int + } + + // MARK: - Workflow Implementation + + func run(input: DeliveryInput) async throws -> String { + // Step 1: Assign a driver + let driverInfo = try await Workflow.executeActivity( + PizzaActivities.Activities.AssignDriver.self, + options: .init(startToCloseTimeout: .seconds(30)), + input: PizzaActivities.AssignDriverInput( + orderId: input.orderId, + address: input.address, + itemCount: input.itemCount + ) + ) + + // Step 2: Simulate delivery + let deliveryResult = try await Workflow.executeActivity( + PizzaActivities.Activities.DeliverOrder.self, + options: .init(startToCloseTimeout: .seconds(30)), + input: PizzaActivities.DeliverOrderInput( + orderId: input.orderId, + driverName: driverInfo.driverName, + address: input.address, + phone: input.phone + ) + ) + + return "\(driverInfo.driverName) (Driver #\(driverInfo.driverNumber)) - \(deliveryResult)" + } +} diff --git a/Examples/ChildWorkflows/ChildWorkflowExample.swift b/Examples/ChildWorkflows/ChildWorkflowExample.swift new file mode 100644 index 0000000..994e5b8 --- /dev/null +++ b/Examples/ChildWorkflows/ChildWorkflowExample.swift @@ -0,0 +1,179 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Temporal SDK open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift Temporal SDK project authors +// Licensed under MIT License +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Temporal SDK project authors +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +#if GRPCNIOTransport +import Foundation +import GRPCNIOTransportHTTP2Posix +import Logging +import Temporal + +/// Child Workflows Example - Pizza Restaurant. +/// +/// This example demonstrates Temporal child workflows through a pizza restaurant order. +/// fulfillment system. It showcases: +/// +/// **Parent Workflow:** +/// - `PizzaOrderWorkflow` - Orchestrates the complete order fulfillment process +/// +/// **Child Workflows:** +/// - `MakePizzaWorkflow` - Makes individual pizzas (executed in parallel) +/// - `PrepareSidesWorkflow` - Prepares sides (executed in parallel with pizzas) +/// - `AssignDeliveryWorkflow` - Assigns driver and handles delivery (sequential) +/// +/// **Key Patterns:** +/// - Parallel child workflow execution using task groups (multiple pizzas) +/// - Sequential child workflow execution (delivery after cooking) +/// - Custom workflow IDs for child workflows +/// - Result aggregation from multiple child workflows +@main +struct ChildWorkflowExample { + static func main() async throws { + let logger = Logger(label: "TemporalWorker") + + let namespace = "default" + let taskQueue = "pizza-queue" + + // Create worker configuration + let workerConfiguration = TemporalWorker.Configuration( + namespace: namespace, + taskQueue: taskQueue, + instrumentation: .init(serverHostname: "localhost") + ) + + // Create the worker with all workflows registered + let worker = try TemporalWorker( + configuration: workerConfiguration, + target: .ipv4(address: "127.0.0.1", port: 7233), + transportSecurity: .plaintext, + activityContainers: PizzaActivities(), + activities: [], + workflows: [ + PizzaOrderWorkflow.self, + MakePizzaWorkflow.self, + PrepareSidesWorkflow.self, + AssignDeliveryWorkflow.self, + ], + logger: logger + ) + + let client = try TemporalClient( + target: .ipv4(address: "127.0.0.1", port: 7233), + transportSecurity: .plaintext, + configuration: .init( + instrumentation: .init( + serverHostname: "localhost" + ) + ), + logger: logger + ) + + try await withThrowingTaskGroup { group in + group.addTask { + try await worker.run() + } + + group.addTask { + try await client.run() + } + + // Wait for the worker and client to initialize + try await Task.sleep(for: .seconds(1)) + + print("🍕 Pizza Restaurant - Child Workflows Example") + print(String(repeating: "=", count: 60)) + print() + + // Create a sample order + let orderId = "ORDER-\(Int.random(in: 10000...99999))" + let orderInput = PizzaOrderWorkflow.OrderInput( + orderId: orderId, + pizzas: [ + PizzaOrderWorkflow.PizzaSpec(size: "large", toppings: ["pepperoni", "mushrooms", "olives"]), + PizzaOrderWorkflow.PizzaSpec(size: "large", toppings: ["sausage", "peppers", "onions"]), + PizzaOrderWorkflow.PizzaSpec(size: "medium", toppings: ["margherita"]), + ], + sides: ["wings", "garlic bread"], + deliveryAddress: "123 Main St, Apt 4B", + customerPhone: "555-0123" + ) + + print("📝 New Order: \(orderId)") + print(" • \(orderInput.pizzas.count) pizza(s)") + for (index, pizza) in orderInput.pizzas.enumerated() { + print(" - Pizza #\(index + 1): \(pizza.size) with \(pizza.toppings.joined(separator: ", "))") + } + print(" • Sides: \(orderInput.sides.joined(separator: ", "))") + print(" • Delivery to: \(orderInput.deliveryAddress)") + print() + + let workflowId = "pizza-order-" + UUID().uuidString + print("🔗 View in Temporal UI:") + print(" http://localhost:8233/namespaces/\(namespace)/workflows/\(workflowId)") + print() + print("⏳ Executing workflow...") + print() + + // Start workflow + let handle = try await client.startWorkflow( + type: PizzaOrderWorkflow.self, + options: .init(id: workflowId, taskQueue: taskQueue), + input: orderInput + ) + + // Wait for result + let result = try await handle.result() + + print() + print(String(repeating: "=", count: 60)) + print("✅ Order Completed!") + print(String(repeating: "=", count: 60)) + print("Order ID: \(result.orderId)") + print() + print("Pizzas:") + for pizzaResult in result.pizzaResults { + print(" ✓ \(pizzaResult)") + } + print() + print("Sides:") + print(" ✓ \(result.sidesResult)") + print() + print("Delivery:") + print(" ✓ \(result.deliveryResult)") + print() + print("Total Time: \(result.totalTime)") + print() + + print(String(repeating: "=", count: 60)) + print("Child Workflows Demonstrated:") + print(" • \(orderInput.pizzas.count) MakePizzaWorkflow children (parallel)") + print(" • 1 PrepareSidesWorkflow child (parallel with pizzas)") + print(" • 1 AssignDeliveryWorkflow child (sequential)") + print() + print("View parent and child workflows in Temporal UI:") + print(" http://localhost:8233") + print(String(repeating: "=", count: 60)) + + // Cancel the client and worker + group.cancelAll() + } + } +} +#else +@main +struct ChildWorkflowExample { + static func main() async throws { + fatalError("GRPCNIOTransport trait disabled") + } +} +#endif diff --git a/Examples/ChildWorkflows/MakePizzaWorkflow.swift b/Examples/ChildWorkflows/MakePizzaWorkflow.swift new file mode 100644 index 0000000..9dc003b --- /dev/null +++ b/Examples/ChildWorkflows/MakePizzaWorkflow.swift @@ -0,0 +1,62 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Temporal SDK open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift Temporal SDK project authors +// Licensed under MIT License +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Temporal SDK project authors +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import Temporal + +/// Child workflow that makes a single pizza. +/// +/// Demonstrates a typical child workflow with multiple activities. +@Workflow +final class MakePizzaWorkflow { + // MARK: - Input/Output Types + + struct PizzaInput: Codable { + let pizzaNumber: Int + let size: String + let toppings: [String] + } + + // MARK: - Workflow Implementation + + func run(input: PizzaInput) async throws -> String { + // Step 1: Prepare the dough + _ = try await Workflow.executeActivity( + PizzaActivities.Activities.PrepareDough.self, + options: .init(startToCloseTimeout: .seconds(30)), + input: PizzaActivities.PrepareDoughInput(size: input.size) + ) + + // Step 2: Add toppings + _ = try await Workflow.executeActivity( + PizzaActivities.Activities.AddToppings.self, + options: .init(startToCloseTimeout: .seconds(30)), + input: PizzaActivities.AddToppingsInput( + toppings: input.toppings, + size: input.size + ) + ) + + // Step 3: Bake the pizza + let bakeResult = try await Workflow.executeActivity( + PizzaActivities.Activities.BakePizza.self, + options: .init(startToCloseTimeout: .seconds(30)), + input: PizzaActivities.BakePizzaInput( + size: input.size, + toppings: input.toppings + ) + ) + + return "Pizza #\(input.pizzaNumber) (\(input.size), \(input.toppings.joined(separator: ", "))) - \(bakeResult)" + } +} diff --git a/Examples/ChildWorkflows/PizzaActivities.swift b/Examples/ChildWorkflows/PizzaActivities.swift new file mode 100644 index 0000000..c7e2052 --- /dev/null +++ b/Examples/ChildWorkflows/PizzaActivities.swift @@ -0,0 +1,138 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Temporal SDK open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift Temporal SDK project authors +// Licensed under MIT License +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Temporal SDK project authors +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import Foundation +import Temporal + +/// Activities for pizza restaurant operations. +@ActivityContainer +struct PizzaActivities { + // MARK: - Activity Input Types + + struct PrepareDoughInput: Codable { + let size: String + } + + struct AddToppingsInput: Codable { + let toppings: [String] + let size: String + } + + struct BakePizzaInput: Codable { + let size: String + let toppings: [String] + } + + struct PrepareSidesInput: Codable { + let sides: [String] + } + + struct AssignDriverInput: Codable { + let orderId: String + let address: String + let itemCount: Int + } + + struct AssignDriverOutput: Codable { + let driverName: String + let driverNumber: Int + let estimatedMinutes: Int + } + + struct DeliverOrderInput: Codable { + let orderId: String + let driverName: String + let address: String + let phone: String + } + + // MARK: - Pizza Making Activities + + /// Prepares pizza dough. + @Activity + func prepareDough(input: PrepareDoughInput) async throws -> String { + let prepTime: Int + switch input.size.lowercased() { + case "small": prepTime = 2 + case "medium": prepTime = 3 + case "large": prepTime = 4 + default: prepTime = 3 + } + + try await Task.sleep(for: .seconds(Double(prepTime))) + return "dough ready" + } + + /// Adds toppings to pizza. + @Activity + func addToppings(input: AddToppingsInput) async throws -> String { + let toppingTime = input.toppings.count + try await Task.sleep(for: .seconds(Double(toppingTime))) + return "\(input.toppings.count) toppings added" + } + + /// Bakes the pizza. + @Activity + func bakePizza(input: BakePizzaInput) async throws -> String { + let bakeTime: Int + switch input.size.lowercased() { + case "small": bakeTime = 8 + case "medium": bakeTime = 10 + case "large": bakeTime = 12 + default: bakeTime = 10 + } + + try await Task.sleep(for: .seconds(Double(bakeTime))) + return "ready" + } + + // MARK: - Sides Activities + + /// Prepares side items. + @Activity + func prepareSides(input: PrepareSidesInput) async throws -> String { + let prepTime = input.sides.count * 3 + try await Task.sleep(for: .seconds(Double(prepTime))) + return "ready" + } + + // MARK: - Delivery Activities + + /// Assigns a driver to the delivery. + @Activity + func assignDriver(input: AssignDriverInput) async throws -> AssignDriverOutput { + try await Task.sleep(for: .seconds(2)) + + let drivers = ["Sarah", "Mike", "Jessica", "David", "Emma"] + let driverIndex = abs(input.orderId.hashValue) % drivers.count + let driverName = drivers[driverIndex] + let driverNumber = (driverIndex + 1) * 10 + Int.random(in: 1...9) + + // Estimate delivery time based on item count + let estimatedMinutes = 20 + (input.itemCount * 2) + + return AssignDriverOutput( + driverName: driverName, + driverNumber: driverNumber, + estimatedMinutes: estimatedMinutes + ) + } + + /// Delivers the order. + @Activity + func deliverOrder(input: DeliverOrderInput) async throws -> String { + try await Task.sleep(for: .seconds(3)) + return "delivered to \(input.address)" + } +} diff --git a/Examples/ChildWorkflows/PizzaOrderWorkflow.swift b/Examples/ChildWorkflows/PizzaOrderWorkflow.swift new file mode 100644 index 0000000..86b7a6b --- /dev/null +++ b/Examples/ChildWorkflows/PizzaOrderWorkflow.swift @@ -0,0 +1,135 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Temporal SDK open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift Temporal SDK project authors +// Licensed under MIT License +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Temporal SDK project authors +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import Temporal + +/// Parent workflow that orchestrates pizza order fulfillment. +/// +/// Demonstrates parallel and sequential child workflow execution. +@Workflow +final class PizzaOrderWorkflow { + // MARK: - Input/Output Types + + struct OrderInput: Codable { + let orderId: String + let pizzas: [PizzaSpec] + let sides: [String] + let deliveryAddress: String + let customerPhone: String + } + + struct PizzaSpec: Codable { + let size: String + let toppings: [String] + } + + struct OrderOutput: Codable { + let orderId: String + let pizzaResults: [String] + let sidesResult: String + let deliveryResult: String + let totalTime: String + } + + private let orderId: String + + init(input: OrderInput) { + self.orderId = input.orderId + } + + // MARK: - Workflow Implementation + + func run(input: OrderInput) async throws -> OrderOutput { + let startTime = Workflow.now + + print("📦 Order \(input.orderId) - Starting fulfillment") + print(" \(input.pizzas.count) pizza(s), sides: \(input.sides.joined(separator: ", "))") + + // Stage 1: Prepare pizzas and sides in parallel + print("\n🍕 Stage 1: Kitchen preparation (parallel execution)") + + // Execute multiple pizza child workflows in parallel + let pizzaResults = try await withThrowingTaskGroup(of: (Int, String).self) { group in + for (index, pizzaSpec) in input.pizzas.enumerated() { + group.addTask { + let handle = try await Workflow.startChildWorkflow( + MakePizzaWorkflow.self, + options: .init(id: "\(input.orderId)-pizza-\(index + 1)"), + input: MakePizzaWorkflow.PizzaInput( + pizzaNumber: index + 1, + size: pizzaSpec.size, + toppings: pizzaSpec.toppings + ) + ) + let result = try await handle.result() + return (index, result) + } + } + + var results: [String] = Array(repeating: "", count: input.pizzas.count) + for try await (index, result) in group { + results[index] = result + print(" ✓ \(result)") + } + return results + } + + // Execute sides preparation in parallel with pizzas (if any) + let sidesResult: String + if !input.sides.isEmpty { + let sidesHandle = try await Workflow.startChildWorkflow( + PrepareSidesWorkflow.self, + options: .init(id: "\(input.orderId)-sides"), + input: PrepareSidesWorkflow.SidesInput(sides: input.sides) + ) + sidesResult = try await sidesHandle.result() + print(" ✓ \(sidesResult)") + } else { + sidesResult = "No sides ordered" + } + + print(" Kitchen preparation complete!") + + // Stage 2: Package everything + print("\n📦 Stage 2: Packaging order") + try await Workflow.sleep(for: .seconds(2)) + print(" ✓ Order packaged and ready for delivery") + + // Stage 3: Assign delivery (sequential - must happen after cooking) + print("\n🚗 Stage 3: Delivery assignment (sequential execution)") + let deliveryHandle = try await Workflow.startChildWorkflow( + AssignDeliveryWorkflow.self, + options: .init(id: "\(input.orderId)-delivery"), + input: AssignDeliveryWorkflow.DeliveryInput( + orderId: input.orderId, + address: input.deliveryAddress, + phone: input.customerPhone, + itemCount: input.pizzas.count + (input.sides.isEmpty ? 0 : 1) + ) + ) + let deliveryResult = try await deliveryHandle.result() + print(" ✓ \(deliveryResult)") + + let endTime = Workflow.now + let totalMinutes = Int(endTime.timeIntervalSince(startTime) / 60) + + return OrderOutput( + orderId: input.orderId, + pizzaResults: pizzaResults, + sidesResult: sidesResult, + deliveryResult: deliveryResult, + totalTime: "\(totalMinutes) minutes" + ) + } +} diff --git a/Examples/ChildWorkflows/PrepareSidesWorkflow.swift b/Examples/ChildWorkflows/PrepareSidesWorkflow.swift new file mode 100644 index 0000000..f54a8db --- /dev/null +++ b/Examples/ChildWorkflows/PrepareSidesWorkflow.swift @@ -0,0 +1,40 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Temporal SDK open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift Temporal SDK project authors +// Licensed under MIT License +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Temporal SDK project authors +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import Temporal + +/// Child workflow that prepares side items. +/// +/// Can run in parallel with pizza preparation. +@Workflow +final class PrepareSidesWorkflow { + // MARK: - Input/Output Types + + struct SidesInput: Codable { + let sides: [String] + } + + // MARK: - Workflow Implementation + + func run(input: SidesInput) async throws -> String { + // Prepare all sides + let result = try await Workflow.executeActivity( + PizzaActivities.Activities.PrepareSides.self, + options: .init(startToCloseTimeout: .seconds(30)), + input: PizzaActivities.PrepareSidesInput(sides: input.sides) + ) + + return "Sides: \(input.sides.joined(separator: ", ")) - \(result)" + } +} diff --git a/Examples/ChildWorkflows/README.md b/Examples/ChildWorkflows/README.md new file mode 100644 index 0000000..83d3bca --- /dev/null +++ b/Examples/ChildWorkflows/README.md @@ -0,0 +1,125 @@ +# Child Workflows - Pizza Restaurant + +Demonstrates parent and child workflow orchestration through a pizza restaurant order fulfillment system. + +## Features + +**Child Workflow Patterns:** +- Parallel child workflows for multiple pizzas +- Parallel execution of sides with pizza preparation +- Sequential child workflow for delivery after cooking +- Custom workflow IDs for tracking +- Result aggregation from multiple children + +**Workflows:** +- `PizzaOrderWorkflow` (parent) - Orchestrates complete order +- `MakePizzaWorkflow` (child) - Prepares individual pizzas +- `PrepareSidesWorkflow` (child) - Prepares sides +- `AssignDeliveryWorkflow` (child) - Handles delivery + +## Usage + +Start Temporal server: +```bash +temporal server start-dev +``` + +Run the example: +```bash +swift run ChildWorkflowExample +``` + +The example demonstrates: +1. Order with 3 pizzas and sides received +2. Pizza child workflows execute in parallel +3. Sides child workflow executes concurrently +4. Delivery child workflow executes sequentially +5. Results aggregated and returned + +## Key Patterns + +**Starting parallel child workflows with task groups:** +```swift +let results = try await withThrowingTaskGroup(of: (Int, String).self) { group in + for (index, pizzaSpec) in pizzas.enumerated() { + group.addTask { + let handle = try await Workflow.startChildWorkflow( + MakePizzaWorkflow.self, + options: .init(id: "order-\(orderID)-pizza-\(index + 1)"), + input: pizzaSpec + ) + return (index, try await handle.result()) + } + } + + var results: [String] = Array(repeating: "", count: pizzas.count) + for try await (index, result) in group { + results[index] = result + } + return results +} +``` + +**Sequential child workflow execution:** +```swift +let deliveryHandle = try await Workflow.startChildWorkflow( + AssignDeliveryWorkflow.self, + options: .init(id: "\(orderId)-delivery"), + input: deliveryInfo +) +let result = try await deliveryHandle.result() +``` + +**Using `executeChildWorkflow` convenience method:** +```swift +let result = try await Workflow.executeChildWorkflow( + MakePizzaWorkflow.self, + input: pizzaSpec +) +``` + +View workflows in Temporal UI: `http://localhost:8233` +- Parent workflow shows child workflow executions +- Each child appears as separate workflow +- Observe parallel execution timing + +## Example Output + +``` +🍕 Pizza Restaurant - Child Workflows Example +============================================================ + +📝 New Order: ORDER-20568 + • 3 pizza(s) + - Pizza #1: large with pepperoni, mushrooms, olives + - Pizza #2: large with sausage, peppers, onions + - Pizza #3: medium with margherita + • Sides: wings, garlic bread + • Delivery to: 123 Main St, Apt 4B + +⏳ Executing workflow... + +📦 Order ORDER-20568 - Starting fulfillment + 3 pizza(s), sides: wings, garlic bread + +🍕 Stage 1: Kitchen preparation (parallel execution) + ✓ Pizza #3 (medium, margherita) - ready + ✓ Pizza #2 (large, sausage, peppers, onions) - ready + ✓ Pizza #1 (large, pepperoni, mushrooms, olives) - ready + ✓ Sides: wings, garlic bread - ready + Kitchen preparation complete! + +📦 Stage 2: Packaging order + ✓ Order packaged and ready for delivery + +🚗 Stage 3: Delivery assignment (sequential execution) + ✓ David (Driver #49) - delivered to 123 Main St, Apt 4B + +============================================================ +✅ Order Completed! +============================================================ +Child Workflows Demonstrated: + • 3 MakePizzaWorkflow children (parallel) + • 1 PrepareSidesWorkflow child (parallel with pizzas) + • 1 AssignDeliveryWorkflow child (sequential) +``` diff --git a/Examples/ErrorHandling/ErrorHandlingActivities.swift b/Examples/ErrorHandling/ErrorHandlingActivities.swift new file mode 100644 index 0000000..8f87aee --- /dev/null +++ b/Examples/ErrorHandling/ErrorHandlingActivities.swift @@ -0,0 +1,260 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Temporal SDK open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift Temporal SDK project authors +// Licensed under MIT License +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Temporal SDK project authors +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import Foundation +import Temporal + +/// Activities for the travel booking workflow demonstrating error handling patterns. +/// +/// Each activity simulates interactions with external services (booking systems, payment gateways). +/// and demonstrates different error handling scenarios. +@ActivityContainer +struct ErrorHandlingActivities { + // MARK: - Activity Input Types + + struct FlightReservation: Codable { + let flightId: String + let customerId: String + } + + struct HotelReservation: Codable { + let hotelId: String + let customerId: String + } + + struct PaymentRequest: Codable { + let customerId: String + let amount: Double + let simulateFailure: Bool + } + + struct CancellationRequest: Codable { + let reservationId: String + let simulateFailure: Bool + } + + // MARK: - Fake Services + + private let reservationService: ReservationService + private let paymentService: PaymentServiceProtocol + + init( + reservationService: ReservationService, + paymentService: PaymentServiceProtocol + ) { + self.reservationService = reservationService + self.paymentService = paymentService + } + + init() { + self.reservationService = FakeReservationService() + self.paymentService = FakePaymentService() + } + + // MARK: - Activities + + /// Reserves a flight - demonstrates automatic retry on transient failures. + @Activity + func reserveFlight(input: FlightReservation) async throws -> String { + print("✈️ Reserving flight \(input.flightId) for customer \(input.customerId)...") + + let reservationId = try await reservationService.reserveFlight( + flightId: input.flightId, + customerId: input.customerId + ) + + print("✅ Flight reserved: \(reservationId)") + return reservationId + } + + /// Reserves a hotel - demonstrates automatic retry on transient failures. + @Activity + func reserveHotel(input: HotelReservation) async throws -> String { + print("🏨 Reserving hotel \(input.hotelId) for customer \(input.customerId)...") + + let reservationId = try await reservationService.reserveHotel( + hotelId: input.hotelId, + customerId: input.customerId + ) + + print("✅ Hotel reserved: \(reservationId)") + return reservationId + } + + /// Charges payment - demonstrates non-retryable business errors. + @Activity + func chargePayment(input: PaymentRequest) async throws -> String { + print("💳 Charging payment of $\(input.amount) for customer \(input.customerId)...") + + if input.simulateFailure { + // Simulate insufficient funds error (non-retryable) + print("❌ Payment failed: Insufficient funds") + throw ApplicationError( + message: "Insufficient funds to complete purchase", + type: "InsufficientFunds", + isNonRetryable: true + ) + } + + let paymentId = try await paymentService.charge( + customerId: input.customerId, + amount: input.amount + ) + + print("✅ Payment successful: \(paymentId)") + return paymentId + } + + /// Cancels flight reservation - compensation activity. + @Activity + func cancelFlight(input: CancellationRequest) async throws { + print("🔄 Cancelling flight reservation \(input.reservationId)...") + + if input.simulateFailure { + print("❌ Flight cancellation failed: Airline API timeout") + throw ApplicationError( + message: "Airline reservation system unavailable - unable to cancel flight", + type: "CancellationFailed", + isNonRetryable: true + ) + } + + try await reservationService.cancelFlight(reservationId: input.reservationId) + + print("✅ Flight reservation cancelled") + } + + /// Cancels hotel reservation - compensation activity. + @Activity + func cancelHotel(input: CancellationRequest) async throws { + print("🔄 Cancelling hotel reservation \(input.reservationId)...") + + if input.simulateFailure { + print("❌ Hotel cancellation failed: Hotel booking system unavailable") + throw ApplicationError( + message: "Hotel reservation system down - unable to cancel booking", + type: "CancellationFailed", + isNonRetryable: true + ) + } + + try await reservationService.cancelHotel(reservationId: input.reservationId) + + print("✅ Hotel reservation cancelled") + } +} + +// MARK: - Service Protocols + +/// Protocol for reservation system operations. +protocol ReservationService: Sendable { + func reserveFlight(flightId: String, customerId: String) async throws -> String + func reserveHotel(hotelId: String, customerId: String) async throws -> String + func cancelFlight(reservationId: String) async throws + func cancelHotel(reservationId: String) async throws +} + +/// Protocol for payment gateway operations. +protocol PaymentServiceProtocol: Sendable { + func charge(customerId: String, amount: Double) async throws -> String +} + +// MARK: - Fake Service Implementations + +/// Simulates a reservation system with transient failures. +actor FakeReservationService: ReservationService { + private var reservations: [String: String] = [:] + private var attemptCount: [String: Int] = [:] + + func reserveFlight(flightId: String, customerId: String) async throws -> String { + // Simulate transient failures on first few attempts + let key = "flight-\(flightId)-\(customerId)" + let attempts = attemptCount[key, default: 0] + attemptCount[key] = attempts + 1 + + // Fail first 2 attempts to demonstrate retry + if attempts < 2 { + try await Task.sleep(for: .milliseconds(100)) + let errorType = attempts == 0 ? "Connection timeout" : "Service temporarily unavailable" + print("⚠️ Flight reservation attempt \(attempts + 1) failed: \(errorType)") + throw ApplicationError( + message: errorType, + type: "TransientError", + isNonRetryable: false + ) + } + + // Succeed on 3rd attempt + try await Task.sleep(for: .milliseconds(200)) + let reservationId = "FLIGHT-RES-\(UUID().uuidString.prefix(8))" + reservations[reservationId] = "flight" + attemptCount[key] = 0 // Reset for next use + return reservationId + } + + func reserveHotel(hotelId: String, customerId: String) async throws -> String { + // Simulate transient failures on first attempt + let key = "hotel-\(hotelId)-\(customerId)" + let attempts = attemptCount[key, default: 0] + attemptCount[key] = attempts + 1 + + // Fail first attempt to demonstrate retry + if attempts < 1 { + try await Task.sleep(for: .milliseconds(100)) + print("⚠️ Hotel reservation attempt \(attempts + 1) failed: Database connection timeout") + throw ApplicationError( + message: "Database connection timeout", + type: "TransientError", + isNonRetryable: false + ) + } + + // Succeed on 2nd attempt + try await Task.sleep(for: .milliseconds(200)) + let reservationId = "HOTEL-RES-\(UUID().uuidString.prefix(8))" + reservations[reservationId] = "hotel" + attemptCount[key] = 0 // Reset for next use + return reservationId + } + + func cancelFlight(reservationId: String) async throws { + try await Task.sleep(for: .milliseconds(150)) + reservations.removeValue(forKey: reservationId) + } + + func cancelHotel(reservationId: String) async throws { + try await Task.sleep(for: .milliseconds(150)) + reservations.removeValue(forKey: reservationId) + } +} + +/// Simulates a payment service with idempotency. +actor FakePaymentService: PaymentServiceProtocol { + private var processedPayments: [String: String] = [:] + + func charge(customerId: String, amount: Double) async throws -> String { + // Simulate payment gateway API call delay + try await Task.sleep(for: .milliseconds(400)) + + // Idempotency: return existing payment ID if already processed + let idempotencyKey = "\(customerId)-\(amount)" + if let existingPaymentId = processedPayments[idempotencyKey] { + return existingPaymentId + } + + let paymentId = "PAY-\(UUID().uuidString.prefix(12))" + processedPayments[idempotencyKey] = paymentId + return paymentId + } +} diff --git a/Examples/ErrorHandling/ErrorHandlingExample.swift b/Examples/ErrorHandling/ErrorHandlingExample.swift new file mode 100644 index 0000000..3eb428c --- /dev/null +++ b/Examples/ErrorHandling/ErrorHandlingExample.swift @@ -0,0 +1,261 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Temporal SDK open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift Temporal SDK project authors +// Licensed under MIT License +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Temporal SDK project authors +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +#if GRPCNIOTransport +import Foundation +import GRPCNIOTransportHTTP2Posix +import Logging +import Temporal + +/// Travel Booking Error Handling Example. +/// +/// This example demonstrates Temporal's error handling capabilities through a realistic. +/// travel booking scenario. It showcases: +/// +/// **Scenario 1: Retry with Exponential Backoff** +/// - Transient failures are automatically retried +/// - Shows how Temporal handles temporary service outages +/// - Demonstrates eventual success after retries +/// +/// **Scenario 2: Saga Pattern / Compensation (Success)** +/// - Multi-step transaction where a later step fails +/// - Automatic rollback of earlier successful steps +/// - Shows proper compensation/undo logic +/// +/// **Scenario 3: Workflow Failure (Compensation Fails)** +/// - Multi-step transaction where payment fails +/// - Compensation itself fails (systems are down) +/// - Shows how workflows fail when compensation is impossible +/// - Demonstrates need for manual intervention +/// +/// The example uses structured types (no string parsing) and clean workflow code. +/// (no print statements in workflows, only in activities). +@main +struct ErrorHandlingExample { + static func main() async throws { + let logger = Logger(label: "TemporalWorker") + + let namespace = "default" + let taskQueue = "travel-booking-queue" + + // Create worker configuration + let workerConfiguration = TemporalWorker.Configuration( + namespace: namespace, + taskQueue: taskQueue, + instrumentation: .init(serverHostname: "localhost") + ) + + // Create activities with fake travel booking services + let activities = ErrorHandlingActivities() + + // Create the worker + let worker = try TemporalWorker( + configuration: workerConfiguration, + target: .ipv4(address: "127.0.0.1", port: 7233), + transportSecurity: .plaintext, + activityContainers: activities, + activities: [], + workflows: [ErrorHandlingWorkflow.self], + logger: logger + ) + + let client = try TemporalClient( + target: .ipv4(address: "127.0.0.1", port: 7233), + transportSecurity: .plaintext, + configuration: .init( + instrumentation: .init( + serverHostname: "localhost" + ) + ), + logger: logger + ) + + try await withThrowingTaskGroup { group in + group.addTask { + try await worker.run() + } + + group.addTask { + try await client.run() + } + + // Wait for the worker and client to initialize + try await Task.sleep(for: .seconds(1)) + + print("✈️ Travel Booking Error Handling Example") + print(String(repeating: "=", count: 60)) + print() + + // Scenario 1: Successful booking with automatic retry + print("📋 Scenario 1: Retry with Exponential Backoff") + print(String(repeating: "-", count: 60)) + + let successfulBooking = ErrorHandlingWorkflow.TravelBookingRequest( + customerId: "customer-001", + flightId: "FL-NYC-LAX-101", + hotelId: "HOTEL-LAX-DOWNTOWN", + amount: 999.99, + simulateFailure: false, // Payment will succeed + simulateCompensationFailure: false + ) + + let workflowId1 = "travel-booking-success-" + UUID().uuidString + print("🔗 View in Temporal UI:") + print(" http://localhost:8233/namespaces/\(namespace)/workflows/\(workflowId1)") + print("\nThis scenario shows how Temporal automatically retries transient") + print("failures. Flight and hotel reservations will fail initially but") + print("succeed after retries, demonstrating Temporal's reliability.\n") + + do { + let result = try await client.executeWorkflow( + type: ErrorHandlingWorkflow.self, + options: .init( + id: workflowId1, + taskQueue: taskQueue + ), + input: successfulBooking + ) + + print("\n" + String(repeating: "=", count: 60)) + print("✅ Scenario 1 Complete!") + print(String(repeating: "=", count: 60)) + print("Status: \(result.status)") + print("Message: \(result.message)") + if let flightId = result.flightReservationId { + print("Flight: \(flightId)") + } + if let hotelId = result.hotelReservationId { + print("Hotel: \(hotelId)") + } + if let paymentId = result.paymentId { + print("Payment: \(paymentId)") + } + print() + } catch { + print("Scenario 1 failed unexpectedly: \(error)\n") + } + + // Scenario 2: Compensation/Saga pattern + print("\n📋 Scenario 2: Saga Pattern / Compensation") + print(String(repeating: "-", count: 60)) + + let failedBooking = ErrorHandlingWorkflow.TravelBookingRequest( + customerId: "customer-002", + flightId: "FL-LAX-NYC-202", + hotelId: "HOTEL-NYC-TIMES-SQUARE", + amount: 1499.99, + simulateFailure: true, // Payment will fail with insufficient funds + simulateCompensationFailure: false // Compensation will succeed + ) + + let workflowId2 = "travel-booking-compensation-" + UUID().uuidString + print("🔗 View in Temporal UI:") + print(" http://localhost:8233/namespaces/\(namespace)/workflows/\(workflowId2)") + print("\nThis scenario demonstrates the Saga pattern. Flight and hotel are") + print("reserved successfully, but payment fails (insufficient funds).") + print("Temporal automatically compensates by cancelling both reservations") + print("in reverse order, ensuring no partial state is left behind.\n") + + do { + let result = try await client.executeWorkflow( + type: ErrorHandlingWorkflow.self, + options: .init( + id: workflowId2, + taskQueue: taskQueue + ), + input: failedBooking + ) + + print("\n" + String(repeating: "=", count: 60)) + print("🔄 Scenario 2 Complete!") + print(String(repeating: "=", count: 60)) + print("Status: \(result.status)") + print("Message: \(result.message)") + if let flightId = result.flightReservationId { + print("Flight (cancelled): \(flightId)") + } + if let hotelId = result.hotelReservationId { + print("Hotel (cancelled): \(hotelId)") + } + print() + } catch { + print("Scenario 2 failed unexpectedly: \(error)\n") + } + + // Scenario 3: Workflow failure (compensation fails) + print("\n📋 Scenario 3: Workflow Failure (Compensation Fails)") + print(String(repeating: "-", count: 60)) + + let criticalFailure = ErrorHandlingWorkflow.TravelBookingRequest( + customerId: "customer-003", + flightId: "FL-SFO-BOS-303", + hotelId: "HOTEL-BOS-HARBOR", + amount: 1899.99, + simulateFailure: true, // Payment will fail + simulateCompensationFailure: true // Compensation will ALSO fail + ) + + let workflowId3 = "travel-booking-critical-" + UUID().uuidString + print("🔗 View in Temporal UI:") + print(" http://localhost:8233/namespaces/\(namespace)/workflows/\(workflowId3)") + print("\nThis scenario demonstrates a critical failure. Flight and hotel are") + print("reserved successfully, but payment fails. When attempting to cancel") + print("the reservations, BOTH cancellation systems are down. The workflow") + print("fails completely, requiring manual intervention to clean up.\n") + + do { + let result = try await client.executeWorkflow( + type: ErrorHandlingWorkflow.self, + options: .init( + id: workflowId3, + taskQueue: taskQueue + ), + input: criticalFailure + ) + + print("\n" + String(repeating: "=", count: 60)) + print("⚠️ Scenario 3 Complete (Unexpected)") + print(String(repeating: "=", count: 60)) + print("Status: \(result.status)") + print("Message: \(result.message)") + print() + } catch { + print("\n" + String(repeating: "=", count: 60)) + print("❌ Scenario 3: WORKFLOW FAILED") + print(String(repeating: "=", count: 60)) + print("This is expected! The workflow failed because compensation") + print("was impossible. In production, this would trigger alerts") + print("for manual intervention.\n") + print("Error details:") + print(error.localizedDescription) + print() + } + + print(String(repeating: "=", count: 60)) + print("Example completed! All three scenarios demonstrated.") + print(String(repeating: "=", count: 60)) + + // Cancel the client and worker + group.cancelAll() + } + } +} +#else +@main +struct ErrorHandlingExample { + static func main() async throws { + fatalError("GRPCNIOTransport trait disabled") + } +} +#endif diff --git a/Examples/ErrorHandling/ErrorHandlingWorkflow.swift b/Examples/ErrorHandling/ErrorHandlingWorkflow.swift new file mode 100644 index 0000000..6ce5f68 --- /dev/null +++ b/Examples/ErrorHandling/ErrorHandlingWorkflow.swift @@ -0,0 +1,210 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Temporal SDK open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift Temporal SDK project authors +// Licensed under MIT License +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Temporal SDK project authors +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import Temporal + +/// Demonstrates error handling and compensation patterns in Temporal workflows. +/// +/// This workflow implements a travel booking system that shows:. +/// - Automatic retry with exponential backoff for transient failures +/// - Saga pattern for distributed transaction compensation +/// - Proper error handling with retryable vs non-retryable errors +@Workflow +final class ErrorHandlingWorkflow { + // MARK: - Input/Output Types + + struct TravelBookingRequest: Codable { + let customerId: String + let flightId: String + let hotelId: String + let amount: Double + let simulateFailure: Bool // For testing compensation scenario + let simulateCompensationFailure: Bool // For testing failed compensation + } + + struct BookingResult: Codable { + let bookingId: String + let status: String + let flightReservationId: String? + let hotelReservationId: String? + let paymentId: String? + let message: String + } + + // MARK: - Workflow Implementation + + func run(input: TravelBookingRequest) async throws -> BookingResult { + let bookingId = "BOOKING-\(input.customerId)-\(Int.random(in: 1000...9999))" + + // Track reservations for potential compensation + var flightReservationId: String? + var hotelReservationId: String? + + do { + // Step 1: Reserve flight + // This activity demonstrates retry on transient failures + flightReservationId = try await Workflow.executeActivity( + ErrorHandlingActivities.Activities.ReserveFlight.self, + options: .init( + startToCloseTimeout: .seconds(30), + retryPolicy: .init( + initialInterval: .milliseconds(500), + backoffCoefficient: 2.0, + maximumInterval: .seconds(10), + maximumAttempts: 5 + ) + ), + input: ErrorHandlingActivities.FlightReservation( + flightId: input.flightId, + customerId: input.customerId + ) + ) + + // Step 2: Reserve hotel + // This activity also demonstrates retry on transient failures + hotelReservationId = try await Workflow.executeActivity( + ErrorHandlingActivities.Activities.ReserveHotel.self, + options: .init( + startToCloseTimeout: .seconds(30), + retryPolicy: .init( + initialInterval: .milliseconds(500), + backoffCoefficient: 2.0, + maximumInterval: .seconds(10), + maximumAttempts: 5 + ) + ), + input: ErrorHandlingActivities.HotelReservation( + hotelId: input.hotelId, + customerId: input.customerId + ) + ) + + // Step 3: Charge payment + // This demonstrates non-retryable errors (insufficient funds, invalid card) + let paymentId = try await Workflow.executeActivity( + ErrorHandlingActivities.Activities.ChargePayment.self, + options: .init( + startToCloseTimeout: .seconds(60), + retryPolicy: .init( + initialInterval: .seconds(1), + backoffCoefficient: 2.0, + maximumInterval: .seconds(30), + maximumAttempts: 5, + nonRetryableErrorTypes: ["InsufficientFunds", "InvalidCard"] + ) + ), + input: ErrorHandlingActivities.PaymentRequest( + customerId: input.customerId, + amount: input.amount, + simulateFailure: input.simulateFailure + ) + ) + + // Success! All steps completed + return BookingResult( + bookingId: bookingId, + status: "confirmed", + flightReservationId: flightReservationId, + hotelReservationId: hotelReservationId, + paymentId: paymentId, + message: "Travel booking completed successfully" + ) + + } catch { + // Compensation: Rollback in reverse order + // This is the Saga pattern - compensate for partial success + + var compensationErrors: [String] = [] + + if let hotelResId = hotelReservationId { + // Cancel hotel reservation + do { + try await Workflow.executeActivity( + ErrorHandlingActivities.Activities.CancelHotel.self, + options: .init( + startToCloseTimeout: .seconds(30), + retryPolicy: .init( + initialInterval: .milliseconds(500), + backoffCoefficient: 2.0, + maximumInterval: .seconds(10), + maximumAttempts: 3 + ) + ), + input: ErrorHandlingActivities.CancellationRequest( + reservationId: hotelResId, + simulateFailure: input.simulateCompensationFailure + ) + ) + } catch { + // Track compensation failure + compensationErrors.append("Hotel cancellation failed: \(error.localizedDescription)") + } + } + + if let flightResId = flightReservationId { + // Cancel flight reservation + do { + try await Workflow.executeActivity( + ErrorHandlingActivities.Activities.CancelFlight.self, + options: .init( + startToCloseTimeout: .seconds(30), + retryPolicy: .init( + initialInterval: .milliseconds(500), + backoffCoefficient: 2.0, + maximumInterval: .seconds(10), + maximumAttempts: 3 + ) + ), + input: ErrorHandlingActivities.CancellationRequest( + reservationId: flightResId, + simulateFailure: input.simulateCompensationFailure + ) + ) + } catch { + // Track compensation failure + compensationErrors.append("Flight cancellation failed: \(error.localizedDescription)") + } + } + + // If compensation itself failed, this is a critical error requiring manual intervention + if !compensationErrors.isEmpty { + let errorMessage = """ + Critical: Booking failed AND compensation failed. Manual intervention required. + Original error: \(error.localizedDescription) + Compensation errors: + \(compensationErrors.map { " - \($0)" }.joined(separator: "\n")) + Reservations requiring manual cleanup: + - Flight: \(flightReservationId ?? "none") + - Hotel: \(hotelReservationId ?? "none") + """ + + throw ApplicationError( + message: errorMessage, + type: "CompensationFailed", + isNonRetryable: true + ) + } + + // Compensation succeeded - return cancelled status + return BookingResult( + bookingId: bookingId, + status: "cancelled", + flightReservationId: flightReservationId, + hotelReservationId: hotelReservationId, + paymentId: nil, + message: "Booking failed: \(error.localizedDescription). All reservations cancelled successfully." + ) + } + } +} diff --git a/Examples/ErrorHandling/README.md b/Examples/ErrorHandling/README.md new file mode 100644 index 0000000..e01089c --- /dev/null +++ b/Examples/ErrorHandling/README.md @@ -0,0 +1,222 @@ +# Travel Booking Error Handling Example + +This example demonstrates Temporal's error handling and compensation patterns through an unrealistic travel booking workflow. It showcases two critical patterns: **automatic retry with exponential backoff** and the **Saga pattern for distributed transactions**. + +## Business Scenario + +The workflow implements a travel booking system with three sequential steps: + +``` +1. Reserve Flight → Book flight reservation +2. Reserve Hotel → Book hotel reservation +3. Charge Payment → Process customer payment +``` + +In a realistic example we would charge the payment first, but since this example is here to showcase error handling we'll do it unrealistical. + +If any step fails, the workflow must handle it appropriately: +- **Transient failures** (network timeout, service unavailable) → Retry automatically. +- **Business errors** (insufficient funds, invalid card) → Don't retry, compensate previous steps. + +## Three Scenarios + +### Scenario 1: Retry with Exponential Backoff + +**What happens:** +- Flight reservation fails twice (connection timeout, service unavailable) +- Temporal automatically retries with exponential backoff +- Flight reservation succeeds on 3rd attempt +- Hotel reservation fails once (database timeout) +- Temporal retries, hotel reservation succeeds on 2nd attempt +- Payment processes successfully +- **Result**: Booking confirmed ✅ + +### Scenario 2: Saga Pattern / Compensation + +**What happens:** +- Flight reservation succeeds (after retries) +- Hotel reservation succeeds (after retries) +- Payment fails with "Insufficient Funds" (non-retryable error) +- Temporal triggers compensation logic + - Compensation happens in **reverse order** (hotel before flight). + - Each compensation step is a separate activity with its own retry policy. + - Workflow tracks reservation IDs using structured types. +- Hotel reservation cancelled +- Flight reservation cancelled +- **Result**: Booking cancelled, no partial state left behind 🔄 + +### Scenario 3: Workflow Failure (Compensation Fails) + +**What happens:** +- Flight reservation succeeds (after retries) +- Hotel reservation succeeds (after retries) +- Payment fails with "Insufficient Funds" (non-retryable error) +- Temporal triggers compensation logic + - Compensation happens in **reverse order** (hotel before flight). + - Each compensation step is a separate activity with its own retry policy. + - Workflow tracks reservation IDs using structured types. +- Hotel cancellation **fails** (hotel booking system down) +- Flight cancellation **fails** (airline API timeout) +- **Result**: Workflow FAILS with critical error ❌ + +## Workflow Logic + +The workflow demonstrates proper compensation in the Saga pattern: + +```swift +do { + // Step 1: Reserve flight + flightReservationId = try await reserveFlight(...) + + // Step 2: Reserve hotel + hotelReservationId = try await reserveHotel(...) + + // Step 3: Charge payment + paymentId = try await chargePayment(...) + + return .success + +} catch { + // Compensation: Rollback in reverse order + + if let hotelId = hotelReservationId { + try await cancelHotel(hotelId) // Cancel hotel first + } + + if let flightId = flightReservationId { + try await cancelFlight(flightId) // Cancel flight second + } + + return .cancelled +} +``` + +## Running the Example + +### Prerequisites +Ensure you have a Temporal server running locally: +```bash +temporal server start-dev +``` + +### Execute the Workflow +```bash +swift run ErrorHandlingExample +``` +You can inspect workflow execution history at http://localhost:8233. + +### Expected Output + +``` +✈️ Travel Booking Error Handling Example +============================================================ + +📋 Scenario 1: Retry with Exponential Backoff +------------------------------------------------------------ +✈️ Reserving flight FL-NYC-LAX-101 for customer customer-001... +⚠️ Flight reservation attempt 1 failed: Connection timeout +✈️ Reserving flight FL-NYC-LAX-101 for customer customer-001... +⚠️ Flight reservation attempt 2 failed: Service temporarily unavailable +✈️ Reserving flight FL-NYC-LAX-101 for customer customer-001... +✅ Flight reserved: FLIGHT-RES-B9BDA0E6 +🏨 Reserving hotel HOTEL-LAX-DOWNTOWN for customer customer-001... +⚠️ Hotel reservation attempt 1 failed: Database connection timeout +🏨 Reserving hotel HOTEL-LAX-DOWNTOWN for customer customer-001... +✅ Hotel reserved: HOTEL-RES-AB0EDE18 +💳 Charging payment of $999.99 for customer customer-001... +✅ Payment successful: PAY-0F9A70C7-806 + +============================================================ +✅ Scenario 1 Complete! +============================================================ +Status: confirmed +Message: Travel booking completed successfully +Flight: FLIGHT-RES-B9BDA0E6 +Hotel: HOTEL-RES-AB0EDE18 +Payment: PAY-0F9A70C7-806 + +📋 Scenario 2: Saga Pattern / Compensation +------------------------------------------------------------ +✈️ Reserving flight FL-LAX-NYC-202 for customer customer-002... +⚠️ Flight reservation attempt 1 failed: Connection timeout +✈️ Reserving flight FL-LAX-NYC-202 for customer customer-002... +⚠️ Flight reservation attempt 2 failed: Service temporarily unavailable +✈️ Reserving flight FL-LAX-NYC-202 for customer customer-002... +✅ Flight reserved: FLIGHT-RES-9289531D +🏨 Reserving hotel HOTEL-NYC-TIMES-SQUARE for customer customer-002... +⚠️ Hotel reservation attempt 1 failed: Database connection timeout +🏨 Reserving hotel HOTEL-NYC-TIMES-SQUARE for customer customer-002... +✅ Hotel reserved: HOTEL-RES-20BA3B0E +💳 Charging payment of $1499.99 for customer customer-002... +❌ Payment failed: Insufficient funds +🔄 Cancelling hotel reservation HOTEL-RES-20BA3B0E... +✅ Hotel reservation cancelled +🔄 Cancelling flight reservation FLIGHT-RES-9289531D... +✅ Flight reservation cancelled + +============================================================ +🔄 Scenario 2 Complete! +============================================================ +Status: cancelled +Message: Booking failed: Insufficient funds. All reservations cancelled. +Flight (cancelled): FLIGHT-RES-9289531D +Hotel (cancelled): HOTEL-RES-20BA3B0E + +📋 Scenario 3: Workflow Failure (Compensation Fails) +------------------------------------------------------------ +✈️ Reserving flight FL-SFO-BOS-303 for customer customer-003... +⚠️ Flight reservation attempt 1 failed: Connection timeout +✈️ Reserving flight FL-SFO-BOS-303 for customer customer-003... +⚠️ Flight reservation attempt 2 failed: Service temporarily unavailable +✈️ Reserving flight FL-SFO-BOS-303 for customer customer-003... +✅ Flight reserved: FLIGHT-RES-66721C55 +🏨 Reserving hotel HOTEL-BOS-HARBOR for customer customer-003... +⚠️ Hotel reservation attempt 1 failed: Database connection timeout +🏨 Reserving hotel HOTEL-BOS-HARBOR for customer customer-003... +✅ Hotel reserved: HOTEL-RES-08381B80 +💳 Charging payment of $1899.99 for customer customer-003... +❌ Payment failed: Insufficient funds +🔄 Cancelling hotel reservation HOTEL-RES-08381B80... +❌ Hotel cancellation failed: Hotel booking system unavailable +🔄 Cancelling flight reservation FLIGHT-RES-66721C55... +❌ Flight cancellation failed: Airline API timeout + +============================================================ +❌ Scenario 3: WORKFLOW FAILED +============================================================ +This is expected! The workflow failed because compensation +was impossible. In production, this would trigger alerts +for manual intervention. + +Error details: +Critical: Booking failed AND compensation failed. Manual intervention required. +Reservations requiring manual cleanup: + - Flight: FLIGHT-RES-66721C55 + - Hotel: HOTEL-RES-08381B80 +``` + +## Key Concepts Demonstrated + +### Automatic Retry +Temporal automatically retries failed activities according to the retry policy with exponential backoff. No manual retry code needed. + +### Non-Retryable Errors +Business logic errors (like `InsufficientFunds`, `InvalidCard`) are marked as non-retryable and trigger compensation instead of retry. + +### Saga Pattern +Distributed transactions require compensation when later steps fail: +- Track what was successfully completed (reservation IDs) +- On failure, undo in reverse order +- Each compensation is a separate activity (can be retried) + +**What if compensation fails?** The workflow itself fails (Scenario 3), providing details about what needs manual cleanup. This is correct behavior - better to fail loudly than leave inconsistent state. + +## Production Considerations + +When compensation fails (Scenario 3), production systems should: +- Trigger alerts (PagerDuty/Slack) to operations team +- Create support tickets with reservation details for manual cleanup +- Log failed compensations for analysis + +The workflow provides all necessary information in the error message for manual intervention. + diff --git a/Examples/MultipleActivities/FakeDatabaseClient.swift b/Examples/MultipleActivities/FakeDatabaseClient.swift new file mode 100644 index 0000000..fd0533c --- /dev/null +++ b/Examples/MultipleActivities/FakeDatabaseClient.swift @@ -0,0 +1,142 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Temporal SDK open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift Temporal SDK project authors +// Licensed under MIT License +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Temporal SDK project authors +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import Foundation + +// MARK: - Service Protocols + +/// Simulates an inventory management system. +protocol InventoryService: Sendable { + func checkAvailability(items: [String]) async throws -> Bool + func reserve(orderId: String, items: [String]) async throws +} + +/// Simulates a payment processing service (e.g., Stripe, PayPal). +protocol PaymentService: Sendable { + func charge(customerId: String, amount: Double) async throws -> String +} + +/// Simulates a shipping provider service (e.g., FedEx, UPS). +protocol ShippingService: Sendable { + func createShipment(orderId: String, customerId: String, items: [String]) async throws -> String +} + +/// Simulates a notification service (e.g., email, SMS, push notifications). +protocol NotificationService: Sendable { + func sendConfirmation(customerId: String, orderId: String, trackingNumber: String) async throws +} + +/// Simulates an order management database. +protocol OrderDatabase: Sendable { + func updateStatus(orderId: String, status: String) async throws +} + +// MARK: - Fake Implementations + +/// Simulates inventory management with realistic delays and occasional failures +actor FakeInventoryService: InventoryService { + private var inventory: [String: Int] = [ + "item-001": 10, + "item-002": 5, + "item-003": 15, + "laptop": 8, + "mouse": 50, + "keyboard": 30, + ] + + private var reservations: [String: [String]] = [:] + + func checkAvailability(items: [String]) async throws -> Bool { + // Simulate API call delay + try await Task.sleep(for: .milliseconds(200)) + + for item in items { + guard let stock = inventory[item], stock > 0 else { + return false + } + } + return true + } + + func reserve(orderId: String, items: [String]) async throws { + // Simulate API call delay + try await Task.sleep(for: .milliseconds(150)) + + for item in items { + if let stock = inventory[item], stock > 0 { + inventory[item] = stock - 1 + } + } + reservations[orderId] = items + } +} + +/// Simulates payment processing with realistic delays and idempotency +actor FakePaymentService: PaymentService { + private var processedPayments: [String: String] = [:] + + func charge(customerId: String, amount: Double) async throws -> String { + // Simulate payment gateway API call delay + try await Task.sleep(for: .milliseconds(500)) + + // Idempotency: return existing payment ID if already processed + let idempotencyKey = "\(customerId)-\(amount)" + if let existingPaymentId = processedPayments[idempotencyKey] { + return existingPaymentId + } + + let paymentId = "pay_\(UUID().uuidString.prefix(12))" + processedPayments[idempotencyKey] = paymentId + return paymentId + } +} + +/// Simulates shipping provider API with realistic delays +actor FakeShippingService: ShippingService { + private var shipments: [String: String] = [:] + + func createShipment(orderId: String, customerId: String, items: [String]) async throws -> String { + // Simulate shipping provider API call delay + try await Task.sleep(for: .milliseconds(300)) + + let trackingNumber = "TRK\(Int.random(in: 100_000_000...999_999_999))" + shipments[orderId] = trackingNumber + return trackingNumber + } +} + +/// Simulates notification service (email/SMS) with realistic delays +actor FakeNotificationService: NotificationService { + private var sentNotifications: Set = [] + + func sendConfirmation(customerId: String, orderId: String, trackingNumber: String) async throws { + // Simulate notification service API call delay + try await Task.sleep(for: .milliseconds(250)) + + let notificationKey = "\(customerId)-\(orderId)" + sentNotifications.insert(notificationKey) + } +} + +/// Simulates order management database with realistic delays +actor FakeOrderDatabase: OrderDatabase { + private var orders: [String: String] = [:] + + func updateStatus(orderId: String, status: String) async throws { + // Simulate database write delay + try await Task.sleep(for: .milliseconds(100)) + + orders[orderId] = status + } +} diff --git a/Examples/MultipleActivities/MultipleActivitiesActivities.swift b/Examples/MultipleActivities/MultipleActivitiesActivities.swift new file mode 100644 index 0000000..a62f71a --- /dev/null +++ b/Examples/MultipleActivities/MultipleActivitiesActivities.swift @@ -0,0 +1,171 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Temporal SDK open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift Temporal SDK project authors +// Licensed under MIT License +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Temporal SDK project authors +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import Foundation +import Temporal + +/// Activities representing external service calls in an order fulfillment workflow. +/// +/// Each activity simulates a real-world external system interaction that benefits +/// from Temporal's automatic retry and observability features. +@ActivityContainer +struct MultipleActivitiesActivities { + // MARK: - Activity Input Types + + struct PaymentInput: Codable { + let customerId: String + let amount: Double + } + + struct ReserveInventoryInput: Codable { + let orderId: String + let items: [String] + } + + struct CreateShipmentInput: Codable { + let orderId: String + let customerId: String + let items: [String] + } + + struct SendConfirmationInput: Codable { + let customerId: String + let orderId: String + let trackingNumber: String + } + + struct UpdateOrderStatusInput: Codable { + let orderId: String + let status: String + } + private let inventoryService: InventoryService + private let paymentService: PaymentService + private let shippingService: ShippingService + private let notificationService: NotificationService + private let orderDatabase: OrderDatabase + + init( + inventoryService: InventoryService, + paymentService: PaymentService, + shippingService: ShippingService, + notificationService: NotificationService, + orderDatabase: OrderDatabase + ) { + self.inventoryService = inventoryService + self.paymentService = paymentService + self.shippingService = shippingService + self.notificationService = notificationService + self.orderDatabase = orderDatabase + } + + init() { + self.inventoryService = FakeInventoryService() + self.paymentService = FakePaymentService() + self.shippingService = FakeShippingService() + self.notificationService = FakeNotificationService() + self.orderDatabase = FakeOrderDatabase() + } + + /// Checks if all items are available in inventory. + /// + /// External call to inventory management system. + @Activity + func checkInventory(input: [String]) async throws -> String { + print("📦 Checking inventory for \(input.count) item(s)...") + + let available = try await inventoryService.checkAvailability(items: input) + + guard available else { + print("❌ Some items out of stock") + throw ApplicationError( + message: "One or more items out of stock", + type: "OutOfStock", + isNonRetryable: true + ) + } + print("✅ All items in stock") + return "All items available" + } + + /// Processes payment through payment gateway + /// External call to payment processor. + @Activity + func processPayment(input: PaymentInput) async throws -> String { + print("💳 Processing payment of $\(input.amount) for customer \(input.customerId)...") + + let paymentId = try await paymentService.charge( + customerId: input.customerId, + amount: input.amount + ) + + print("✅ Payment successful: \(paymentId)") + return paymentId + } + + /// Reserves inventory after successful payment. + /// + /// External call to inventory management system to update stock levels. + @Activity + func reserveInventory(input: ReserveInventoryInput) async throws { + print("📦 Reserving inventory for order \(input.orderId)...") + + try await inventoryService.reserve(orderId: input.orderId, items: input.items) + + print("✅ Inventory reserved") + } + + /// Creates shipment and returns tracking number + /// External call to shipping provider. + @Activity + func createShipment(input: CreateShipmentInput) async throws -> String { + print("📮 Creating shipment for order \(input.orderId)...") + + let trackingNumber = try await shippingService.createShipment( + orderId: input.orderId, + customerId: input.customerId, + items: input.items + ) + + print("✅ Shipment created: \(trackingNumber)") + return trackingNumber + } + + /// Sends order confirmation to customer. + /// + /// External call to notification service (email, SMS, push notification). + @Activity + func sendConfirmation(input: SendConfirmationInput) async throws { + print("📧 Sending confirmation to customer \(input.customerId)...") + + try await notificationService.sendConfirmation( + customerId: input.customerId, + orderId: input.orderId, + trackingNumber: input.trackingNumber + ) + + print("✅ Confirmation sent") + } + + /// Updates order status in database. + /// + /// External call to order management database. + @Activity + func updateOrderStatus(input: UpdateOrderStatusInput) async throws { + print("💾 Updating order \(input.orderId) status to '\(input.status)'...") + + try await orderDatabase.updateStatus(orderId: input.orderId, status: input.status) + + print("✅ Order status updated") + } +} diff --git a/Examples/MultipleActivities/MultipleActivitiesExample.swift b/Examples/MultipleActivities/MultipleActivitiesExample.swift new file mode 100644 index 0000000..4629341 --- /dev/null +++ b/Examples/MultipleActivities/MultipleActivitiesExample.swift @@ -0,0 +1,149 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Temporal SDK open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift Temporal SDK project authors +// Licensed under MIT License +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Temporal SDK project authors +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +#if GRPCNIOTransport +import Foundation +import GRPCNIOTransportHTTP2Posix +import Logging +import Temporal + +/// Order Fulfillment Example. +/// +/// This example demonstrates how to orchestrate multiple activities in a Temporal workflow +/// to implement a realistic order fulfillment process. It showcases: +/// +/// - **Activity Orchestration**: Coordinating multiple external service calls +/// - **Retry Policies**: Configuring different retry strategies for different operations +/// - **Reliability**: Automatic retries and failure handling for transient errors +/// - **Observability**: Clear logging and activity progress tracking +/// +/// The workflow implements a complete e-commerce order flow: +/// 1. Check inventory availability +/// 2. Process payment +/// 3. Reserve inventory +/// 4. Create shipment +/// 5. Send confirmation +/// 6. Update order status +/// +/// Each step is implemented as a separate activity, representing a call to an external +/// service (payment gateway, shipping provider, notification service, etc.). Temporal +/// ensures reliable execution with automatic retries, even if the worker crashes or +/// activities fail temporarily. +@main +struct MultipleActivitiesExample { + static func main() async throws { + let logger = Logger(label: "TemporalWorker") + + let namespace = "default" + let taskQueue = "order-fulfillment-queue" + + // Create worker configuration + let workerConfiguration = TemporalWorker.Configuration( + namespace: namespace, + taskQueue: taskQueue, + instrumentation: .init(serverHostname: "localhost") + ) + + // Create activities with fake external service implementations + let activities = MultipleActivitiesActivities() + + // Create the worker with activities and workflows + let worker = try TemporalWorker( + configuration: workerConfiguration, + target: .ipv4(address: "127.0.0.1", port: 7233), + transportSecurity: .plaintext, + activityContainers: activities, + activities: [], + workflows: [MultipleActivitiesWorkflow.self], + logger: logger + ) + + let client = try TemporalClient( + target: .ipv4(address: "127.0.0.1", port: 7233), + transportSecurity: .plaintext, + configuration: .init( + instrumentation: .init( + serverHostname: "localhost" + ) + ), + logger: logger + ) + + try await withThrowingTaskGroup { group in + group.addTask { + try await worker.run() + } + + group.addTask { + try await client.run() + } + + // Wait for the worker and client to initialize + try await Task.sleep(for: .seconds(1)) + + print("🛒 Starting Order Fulfillment Workflow Example") + print(String(repeating: "=", count: 60)) + + // Create a sample order + let orderRequest = MultipleActivitiesWorkflow.OrderRequest( + orderId: "ORD-\(UUID().uuidString.prefix(8))", + customerId: "customer-123", + items: ["laptop", "mouse", "keyboard"], + totalAmount: 1299.99 + ) + + print("\n🔗 View in Temporal UI:") + print(" http://localhost:8233/namespaces/\(namespace)/workflows/\(orderRequest.orderId)") + print("\n📋 Order Details:") + print(" Order ID: \(orderRequest.orderId)") + print(" Customer: \(orderRequest.customerId)") + print(" Items: \(orderRequest.items.joined(separator: ", "))") + print(" Total: $\(orderRequest.totalAmount)") + print() + + do { + let result = try await client.executeWorkflow( + type: MultipleActivitiesWorkflow.self, + options: .init(id: orderRequest.orderId, taskQueue: taskQueue), + input: orderRequest + ) + + print("\n" + String(repeating: "=", count: 60)) + print("✅ Order Fulfilled Successfully!") + print(String(repeating: "=", count: 60)) + print("📦 Order Status: \(result.status)") + print("💳 Payment ID: \(result.paymentId)") + print("🚚 Tracking Number: \(result.trackingNumber)") + print() + } catch { + print("\n" + String(repeating: "=", count: 60)) + print("❌ Order Fulfillment Failed") + print(String(repeating: "=", count: 60)) + print("Error: \(error.localizedDescription)") + print() + } + + // Cancel the client and worker + group.cancelAll() + } + } +} +#else +@main +struct MultipleActivitiesExample { + static func main() async throws { + fatalError("GRPCNIOTransport trait disabled") + } +} +#endif diff --git a/Examples/MultipleActivities/MultipleActivitiesWorkflow.swift b/Examples/MultipleActivities/MultipleActivitiesWorkflow.swift new file mode 100644 index 0000000..59ee3e9 --- /dev/null +++ b/Examples/MultipleActivities/MultipleActivitiesWorkflow.swift @@ -0,0 +1,162 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Temporal SDK open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift Temporal SDK project authors +// Licensed under MIT License +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Temporal SDK project authors +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import Temporal + +/// Demonstrates a realistic order fulfillment workflow that orchestrates multiple activities. +/// This example shows: +/// - Breaking down a complex business process into discrete activities +/// - Configuring appropriate retry policies for different types of operations +/// - Passing data between activities in a workflow +/// - Why certain operations should be activities (external API calls, database operations) +@Workflow +final class MultipleActivitiesWorkflow { + struct OrderRequest: Codable { + let orderId: String + let customerId: String + let items: [String] + let totalAmount: Double + } + + struct OrderResult: Codable { + let orderId: String + let status: String + let paymentId: String + let trackingNumber: String + } + + func run(input: OrderRequest) async throws -> OrderResult { + // Step 1: Validate inventory for all items + // This is an activity because it queries external inventory systems + _ = try await Workflow.executeActivity( + MultipleActivitiesActivities.Activities.CheckInventory.self, + options: .init( + startToCloseTimeout: .seconds(30), + retryPolicy: .init( + initialInterval: .milliseconds(500), + backoffCoefficient: 2.0, + maximumInterval: .seconds(10), + maximumAttempts: 3 + ) + ), + input: input.items + ) + + // Step 2: Process payment with payment gateway + // This is an activity because it calls an external payment API + // Payment operations need careful retry handling to avoid double-charging + let paymentId = try await Workflow.executeActivity( + MultipleActivitiesActivities.Activities.ProcessPayment.self, + options: .init( + startToCloseTimeout: .seconds(60), + retryPolicy: .init( + initialInterval: .seconds(1), + backoffCoefficient: 2.0, + maximumInterval: .seconds(30), + maximumAttempts: 5, + nonRetryableErrorTypes: ["InsufficientFunds", "InvalidCard"] + ) + ), + input: MultipleActivitiesActivities.PaymentInput( + customerId: input.customerId, + amount: input.totalAmount + ) + ) + + // Step 3: Reserve inventory after successful payment + // This is an activity because it updates external inventory database + try await Workflow.executeActivity( + MultipleActivitiesActivities.Activities.ReserveInventory.self, + options: .init( + startToCloseTimeout: .seconds(30), + retryPolicy: .init( + initialInterval: .milliseconds(500), + backoffCoefficient: 2.0, + maximumInterval: .seconds(10), + maximumAttempts: 3 + ) + ), + input: MultipleActivitiesActivities.ReserveInventoryInput( + orderId: input.orderId, + items: input.items + ) + ) + + // Step 4: Create shipment and get tracking number + // This is an activity because it integrates with shipping provider APIs + let trackingNumber = try await Workflow.executeActivity( + MultipleActivitiesActivities.Activities.CreateShipment.self, + options: .init( + startToCloseTimeout: .seconds(45), + retryPolicy: .init( + initialInterval: .seconds(1), + backoffCoefficient: 2.0, + maximumInterval: .seconds(15), + maximumAttempts: 4 + ) + ), + input: MultipleActivitiesActivities.CreateShipmentInput( + orderId: input.orderId, + customerId: input.customerId, + items: input.items + ) + ) + + // Step 5: Send confirmation notification to customer + // This is an activity because it calls external notification service (email/SMS) + try await Workflow.executeActivity( + MultipleActivitiesActivities.Activities.SendConfirmation.self, + options: .init( + startToCloseTimeout: .seconds(30), + retryPolicy: .init( + initialInterval: .milliseconds(500), + backoffCoefficient: 2.0, + maximumInterval: .seconds(10), + maximumAttempts: 3 + ) + ), + input: MultipleActivitiesActivities.SendConfirmationInput( + customerId: input.customerId, + orderId: input.orderId, + trackingNumber: trackingNumber + ) + ) + + // Step 6: Update order status in database + // This is an activity because it performs database I/O + try await Workflow.executeActivity( + MultipleActivitiesActivities.Activities.UpdateOrderStatus.self, + options: .init( + startToCloseTimeout: .seconds(30), + retryPolicy: .init( + initialInterval: .milliseconds(500), + backoffCoefficient: 2.0, + maximumInterval: .seconds(10), + maximumAttempts: 3 + ) + ), + input: MultipleActivitiesActivities.UpdateOrderStatusInput( + orderId: input.orderId, + status: "fulfilled" + ) + ) + + return OrderResult( + orderId: input.orderId, + status: "fulfilled", + paymentId: paymentId, + trackingNumber: trackingNumber + ) + } +} diff --git a/Examples/MultipleActivities/README.md b/Examples/MultipleActivities/README.md new file mode 100644 index 0000000..f3cd7c4 --- /dev/null +++ b/Examples/MultipleActivities/README.md @@ -0,0 +1,103 @@ +# Order Fulfillment Example + +This example demonstrates how to orchestrate multiple activities in a Temporal workflow to implement a realistic e-commerce order fulfillment process. It showcases Temporal's reliability, retry handling, and activity orchestration patterns using the Swift Temporal SDK. + +## Activities + +The workflow implements a complete order fulfillment flow with six sequential steps. Each activity represents a call to an external service, benefiting from Temporal's automatic retry and observability: + +### `checkInventory` +- **Purpose**: Validates inventory availability with external inventory management system +- **Retry Strategy**: 3 attempts with exponential backoff +- **Error Handling**: Out-of-stock errors are non-retryable business errors + +### `processPayment` +- **Purpose**: Charges customer through payment gateway (e.g., Stripe, PayPal) +- **Retry Strategy**: 5 attempts with longer timeouts to handle payment gateway delays +- **Error Handling**: `InsufficientFunds` and `InvalidCard` are non-retryable +- **Special Feature**: Demonstrates idempotent payment processing + +### `reserveInventory` +- **Purpose**: Updates stock levels in inventory database after successful payment +- **Retry Strategy**: 3 attempts with quick retries for database operations + +### `createShipment` +- **Purpose**: Creates shipment and returns tracking number from shipping provider API +- **Retry Strategy**: 4 attempts with moderate timeouts for external API calls + +### `sendConfirmation` +- **Purpose**: Sends order confirmation to customer via notification service +- **Retry Strategy**: 3 attempts (notifications can be retried safely) + +### `updateOrderStatus` +- **Purpose**: Updates order status in order management database +- **Retry Strategy**: 3 attempts with quick retries for database operations + +## Retry Configuration + +Each activity uses tailored retry policies. For example: + +```swift +// Payment processing - longer timeouts, non-retryable business errors +retryPolicy: .init( + initialInterval: .seconds(1), + backoffCoefficient: 2.0, + maximumInterval: .seconds(30), + maximumAttempts: 5, + nonRetryableErrorTypes: ["InsufficientFunds", "InvalidCard"] +) + +// Database operations - quick retries, shorter timeouts +retryPolicy: .init( + initialInterval: .milliseconds(500), + backoffCoefficient: 2.0, + maximumInterval: .seconds(10), + maximumAttempts: 3 +) +``` + +## Running the Example + +### Prerequisites +Ensure you have a Temporal server running locally: +```bash +temporal server start-dev +``` + +### Execute the Workflow +```bash +swift run MultipleActivitiesExample +``` + +### Expected Output + +``` +🛒 Starting Order Fulfillment Workflow Example +============================================================ + +📋 Order Details: + Order ID: ORD-DD22C618 + Customer: customer-123 + Items: laptop, mouse, keyboard + Total: $1299.99 + +📦 Checking inventory for 3 item(s)... +✅ All items in stock +💳 Processing payment of $1299.99 for customer customer-123... +✅ Payment successful: pay_AEF5A9AF-678 +📦 Reserving inventory for order ORD-DD22C618... +✅ Inventory reserved +📮 Creating shipment for order ORD-DD22C618... +✅ Shipment created: TRK996996117 +📧 Sending confirmation to customer customer-123... +✅ Confirmation sent +💾 Updating order ORD-DD22C618 status to 'fulfilled'... +✅ Order status updated + +============================================================ +✅ Order Fulfilled Successfully! +============================================================ +📦 Order Status: fulfilled +💳 Payment ID: pay_AEF5A9AF-678 +🚚 Tracking Number: TRK996996117 +``` diff --git a/Examples/Package.swift b/Examples/Package.swift new file mode 100644 index 0000000..a09618d --- /dev/null +++ b/Examples/Package.swift @@ -0,0 +1,91 @@ +// swift-tools-version: 6.2 + +import PackageDescription + +let package = Package( + name: "TemporalExamples", + platforms: [ + .macOS(.v15) + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-temporal-sdk.git", from: "0.1.0") + ], + targets: [ + .executableTarget( + name: "GreetingExample", + dependencies: [ + .product(name: "Temporal", package: "swift-temporal-sdk") + ], + path: "Greeting", + swiftSettings: [ + .define("GRPCNIOTransport") + ] + ), + .executableTarget( + name: "MultipleActivitiesExample", + dependencies: [ + .product(name: "Temporal", package: "swift-temporal-sdk") + ], + path: "MultipleActivities", + exclude: ["README.md"], + swiftSettings: [ + .define("GRPCNIOTransport") + ] + ), + .executableTarget( + name: "ErrorHandlingExample", + dependencies: [ + .product(name: "Temporal", package: "swift-temporal-sdk") + ], + path: "ErrorHandling", + exclude: ["README.md"], + swiftSettings: [ + .define("GRPCNIOTransport") + ] + ), + .executableTarget( + name: "SignalExample", + dependencies: [ + .product(name: "Temporal", package: "swift-temporal-sdk") + ], + path: "Signals", + exclude: ["README.md"], + swiftSettings: [ + .define("GRPCNIOTransport") + ] + ), + .executableTarget( + name: "AsyncActivitiesExample", + dependencies: [ + .product(name: "Temporal", package: "swift-temporal-sdk") + ], + path: "AsyncActivities", + exclude: ["README.md"], + swiftSettings: [ + .define("GRPCNIOTransport") + ] + ), + .executableTarget( + name: "ScheduleExample", + dependencies: [ + .product(name: "Temporal", package: "swift-temporal-sdk") + ], + path: "Schedule", + exclude: ["README.md"], + swiftSettings: [ + .define("GRPCNIOTransport") + ] + ), + .executableTarget( + name: "ChildWorkflowExample", + dependencies: [ + .product(name: "Temporal", package: "swift-temporal-sdk") + ], + path: "ChildWorkflows", + exclude: ["README.md"], + swiftSettings: [ + .define("GRPCNIOTransport") + ] + ), + ] +) diff --git a/Examples/README.md b/Examples/README.md new file mode 100644 index 0000000..ebb9515 --- /dev/null +++ b/Examples/README.md @@ -0,0 +1,55 @@ +# Swift Temporal SDK Samples + +This is the set of Swift samples for the [Swift Temporal SDK](https://github.com/apple/swift-temporal-sdk). + +## Usage + +Prerequisites: + +* Swift version: [Swift 6.2+](https://www.swift.org/install/) +* Local Temporal server running (can [install CLI](https://docs.temporal.io/cli#install) then + [run a dev server](https://docs.temporal.io/cli#start-dev-server)) + +### Building Examples + +The examples are organized as a separate Swift package that depends on the main SDK. To build and run examples: + +```bash +# From the Examples directory +cd Examples +swift build --product + +# Or from the repository root +swift build --package-path Examples --product +``` + +### Running Examples + +After building, run an example: + +```bash +# From Examples directory +swift run + +# Or from repository root +swift run --package-path Examples +``` + +For example, to build and run the Greeting example: + +```bash +cd Examples +swift build --product GreetingExample +swift run GreetingExample +``` + +## Samples + + +* [Async Activities](AsyncActivities) - Demonstrates parallel/concurrent activity execution using NYC's Open Data API to process film permits. +* [Child Workflows](ChildWorkflows) - Demonstrates parent and child workflow orchestration through a pizza restaurant order fulfillment system with parallel and sequential child workflows. +* [Error Handling](ErrorHandling) - Shows advanced error handling patterns including retries, compensation, and failure recovery. +* [Greeting](Greeting) - Simple workflow that returns Hello. +* [Multiple Activities](MultipleActivities) - Demonstrates a workflow with multiple activities and fake database operations using Swift actors. +* [Schedule](Schedule) - Demonstrates Temporal scheduling with live NASA APIs to monitor the International Space Station, showing calendar and interval-based schedules. +* [Signals](Signals) - Demonstrates signals, queries, and updates for interacting with running workflows. diff --git a/Examples/Schedule/Activities.swift b/Examples/Schedule/Activities.swift new file mode 100644 index 0000000..6aa4ffa --- /dev/null +++ b/Examples/Schedule/Activities.swift @@ -0,0 +1,259 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Temporal SDK open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift Temporal SDK project authors +// Licensed under MIT License +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Temporal SDK project authors +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import Foundation +import Temporal + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +// MARK: - API Response Models + +private struct ISSPositionResponse: Codable { + let name: String + let id: Int + let latitude: Double + let longitude: Double + let altitude: Double + let velocity: Double + let visibility: String + let footprint: Double + let timestamp: Int +} + +private struct AstronautInfo: Codable { + let name: String + let craft: String +} + +private struct AstronautsResponse: Codable { + let people: [AstronautInfo] + let number: Int + let message: String +} + +// MARK: - Activity Errors + +enum SpaceAPIError: Error, CustomStringConvertible { + case networkError(String) + case invalidResponse + case decodingError(String) + case apiError(String) + + var description: String { + switch self { + case .networkError(let message): + return "Network error: \(message)" + case .invalidResponse: + return "Invalid response from API" + case .decodingError(let message): + return "Failed to decode response: \(message)" + case .apiError(let message): + return "API error: \(message)" + } + } +} + +// MARK: - Activities + +@ActivityContainer +struct SpaceMissionActivities { + @Activity + func collectRealTelemetry(input: TelemetryRequest) async throws -> TelemetryData { + let urlString = "https://api.wheretheiss.at/v1/satellites/\(input.satelliteId)" + guard let url = URL(string: urlString) else { + throw SpaceAPIError.invalidResponse + } + + var request = URLRequest(url: url) + request.timeoutInterval = 30 + + do { + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw SpaceAPIError.invalidResponse + } + + guard httpResponse.statusCode == 200 else { + throw SpaceAPIError.apiError("HTTP \(httpResponse.statusCode)") + } + + let decoder = JSONDecoder() + let issResponse = try decoder.decode(ISSPositionResponse.self, from: data) + + return TelemetryData( + latitude: issResponse.latitude, + longitude: issResponse.longitude, + altitude: issResponse.altitude, + velocity: issResponse.velocity, + visibility: issResponse.visibility, + footprint: issResponse.footprint, + timestamp: issResponse.timestamp + ) + } catch let error as DecodingError { + throw SpaceAPIError.decodingError(error.localizedDescription) + } catch let error as SpaceAPIError { + throw error + } catch { + throw SpaceAPIError.networkError(error.localizedDescription) + } + } + + @Activity + func checkCrewStatus(input: CrewStatusRequest) async throws -> CrewStatus { + let urlString = "http://api.open-notify.org/astros.json" + guard let url = URL(string: urlString) else { + throw SpaceAPIError.invalidResponse + } + + var request = URLRequest(url: url) + request.timeoutInterval = 30 + + do { + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw SpaceAPIError.invalidResponse + } + + guard httpResponse.statusCode == 200 else { + throw SpaceAPIError.apiError("HTTP \(httpResponse.statusCode)") + } + + let decoder = JSONDecoder() + let astronautsResponse = try decoder.decode(AstronautsResponse.self, from: data) + + // Separate ISS crew from other stations + let issCrew = astronautsResponse.people.filter { $0.craft == "ISS" } + let otherCrew = astronautsResponse.people.filter { $0.craft != "ISS" } + + var otherStations: [String: [String]] = [:] + for person in otherCrew { + if otherStations[person.craft] == nil { + otherStations[person.craft] = [] + } + otherStations[person.craft]?.append(person.name) + } + + return CrewStatus( + totalInSpace: astronautsResponse.number, + issCrewCount: issCrew.count, + issCrewMembers: issCrew.map { $0.name }, + otherStations: otherStations, + timestamp: Date() + ) + } catch let error as DecodingError { + throw SpaceAPIError.decodingError(error.localizedDescription) + } catch let error as SpaceAPIError { + throw error + } catch { + throw SpaceAPIError.networkError(error.localizedDescription) + } + } + + @Activity + func performSystemHealthCheck(input: HealthCheckRequest) async throws -> HealthCheckResult { + // Get real telemetry to assess orbital parameters + let telemetry = try await collectRealTelemetry(input: TelemetryRequest()) + + // Simulate subsystem checks with some randomness + let dataStorage = Double.random(in: 75...95) + let temperature = Double.random(in: 20...24) + + // Assess orbital parameters based on real data + let altitudeInRange = telemetry.altitude >= 400 && telemetry.altitude <= 420 + let velocityInRange = telemetry.velocity >= 27400 && telemetry.velocity <= 27700 + + let orbitalStatus: String + if altitudeInRange && velocityInRange { + orbitalStatus = "Nominal" + } else if !altitudeInRange { + orbitalStatus = "Altitude deviation detected" + } else { + orbitalStatus = "Velocity deviation detected" + } + + let overallStatus: String + if dataStorage < 80 || !altitudeInRange || !velocityInRange { + overallStatus = "Attention required" + } else { + overallStatus = "All systems operational" + } + + return HealthCheckResult( + dataStorageAvailable: dataStorage, + powerSystemsStatus: "Nominal (Solar arrays optimal)", + thermalControlTemp: temperature, + communicationsStatus: "All ground station links active", + orbitalParametersStatus: orbitalStatus, + overallStatus: overallStatus, + timestamp: Date() + ) + } + + @Activity + func executeOrbitCorrection(input: OrbitCorrectionInput) async throws -> OrbitCorrectionResult { + // Calculate required delta-v (simplified) + let altitudeDiff = input.targetAltitude - input.currentAltitude + let deltaV = abs(altitudeDiff) * 0.001 // Simplified calculation + + // Simulate fuel usage + let fuelUsed = deltaV * 100 // kg + + return OrbitCorrectionResult( + success: true, + deltaV: deltaV, + newAltitude: input.targetAltitude, + fuelUsed: fuelUsed, + timestamp: Date() + ) + } + + @Activity + func generateMissionReport(input: ReportRequest) async throws -> MissionReport { + // Gather current data + let telemetry = try await collectRealTelemetry(input: TelemetryRequest()) + let crew = try await checkCrewStatus(input: CrewStatusRequest(filterCraft: "ISS")) + let health = try await performSystemHealthCheck(input: HealthCheckRequest()) + + let reportId = "REPORT-\(UUID().uuidString.prefix(8))" + + let telemetrySummary = """ + Position: \(telemetry.formattedPosition) + Altitude: \(String(format: "%.2f", telemetry.altitude)) km (\(telemetry.altitudeStatus)) + Velocity: \(String(format: "%.2f", telemetry.velocity)) km/h + """ + + let crewSummary = """ + ISS Crew: \(crew.issCrewCount) astronauts + Total in space: \(crew.totalInSpace) + """ + + let healthSummary = """ + Overall: \(health.overallStatus) + Storage: \(String(format: "%.1f", health.dataStorageAvailable))% available + Orbital: \(health.orbitalParametersStatus) + """ + + return MissionReport( + reportId: reportId, + generatedAt: Date(), + telemetrySummary: telemetrySummary, + crewSummary: crewSummary, + healthSummary: healthSummary + ) + } +} diff --git a/Examples/Schedule/Models.swift b/Examples/Schedule/Models.swift new file mode 100644 index 0000000..9c92d30 --- /dev/null +++ b/Examples/Schedule/Models.swift @@ -0,0 +1,263 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Temporal SDK open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift Temporal SDK project authors +// Licensed under MIT License +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Temporal SDK project authors +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import Foundation + +// MARK: - Operation Types + +enum OperationType: String, Codable, Sendable { + case collectTelemetry + case checkCrew + case systemHealth + case orbitCorrection + case generateReport +} + +enum Priority: String, Codable, Sendable { + case routine + case elevated + case critical +} + +enum OperationStatus: String, Codable, Sendable { + case success + case failed + case partial +} + +// MARK: - Request Types + +struct TelemetryRequest: Codable, Sendable { + var missionTime: Int + var satelliteId: Int = 25544 // ISS NORAD catalog ID + + init(missionTime: Int = 0, satelliteId: Int = 25544) { + self.missionTime = missionTime + self.satelliteId = satelliteId + } +} + +struct CrewStatusRequest: Codable, Sendable { + var filterCraft: String? + + init(filterCraft: String? = nil) { + self.filterCraft = filterCraft + } +} + +struct HealthCheckRequest: Codable, Sendable { + var priority: Priority + + init(priority: Priority = .routine) { + self.priority = priority + } +} + +struct OrbitCorrectionInput: Codable, Sendable { + var currentAltitude: Double + var targetAltitude: Double + var burnDuration: Int // seconds + + init(currentAltitude: Double, targetAltitude: Double, burnDuration: Int) { + self.currentAltitude = currentAltitude + self.targetAltitude = targetAltitude + self.burnDuration = burnDuration + } +} + +struct ReportRequest: Codable, Sendable { + var includeHistory: Bool + + init(includeHistory: Bool = false) { + self.includeHistory = includeHistory + } +} + +struct MissionOperationInput: Codable, Sendable { + var operation: OperationType + var priority: Priority + + init(operation: OperationType, priority: Priority = .routine) { + self.operation = operation + self.priority = priority + } +} + +// MARK: - Response Types + +struct TelemetryData: Codable, Sendable { + // Real data from wheretheiss.at API + var latitude: Double + var longitude: Double + var altitude: Double // km + var velocity: Double // km/h + var visibility: String // "daylight" or "eclipsed" + var footprint: Double // km diameter + var timestamp: Int // Unix timestamp + + // Human-readable additions + var formattedPosition: String + var altitudeStatus: String + + init( + latitude: Double, + longitude: Double, + altitude: Double, + velocity: Double, + visibility: String, + footprint: Double, + timestamp: Int + ) { + self.latitude = latitude + self.longitude = longitude + self.altitude = altitude + self.velocity = velocity + self.visibility = visibility + self.footprint = footprint + self.timestamp = timestamp + + // Format position + let latDir = latitude >= 0 ? "N" : "S" + let lonDir = longitude >= 0 ? "E" : "W" + self.formattedPosition = String(format: "%.2f°%@, %.2f°%@", abs(latitude), latDir, abs(longitude), lonDir) + + // Check altitude status (nominal range: 400-420 km) + if altitude >= 400 && altitude <= 420 { + self.altitudeStatus = "Nominal" + } else if altitude < 400 { + self.altitudeStatus = "Low" + } else { + self.altitudeStatus = "High" + } + } +} + +struct CrewStatus: Codable, Sendable { + var totalInSpace: Int + var issCrewCount: Int + var issCrewMembers: [String] + var otherStations: [String: [String]] + var timestamp: Date + + init( + totalInSpace: Int, + issCrewCount: Int, + issCrewMembers: [String], + otherStations: [String: [String]], + timestamp: Date + ) { + self.totalInSpace = totalInSpace + self.issCrewCount = issCrewCount + self.issCrewMembers = issCrewMembers + self.otherStations = otherStations + self.timestamp = timestamp + } +} + +struct HealthCheckResult: Codable, Sendable { + var dataStorageAvailable: Double // percentage + var powerSystemsStatus: String + var thermalControlTemp: Double // Celsius + var communicationsStatus: String + var orbitalParametersStatus: String + var overallStatus: String + var timestamp: Date + + init( + dataStorageAvailable: Double, + powerSystemsStatus: String, + thermalControlTemp: Double, + communicationsStatus: String, + orbitalParametersStatus: String, + overallStatus: String, + timestamp: Date + ) { + self.dataStorageAvailable = dataStorageAvailable + self.powerSystemsStatus = powerSystemsStatus + self.thermalControlTemp = thermalControlTemp + self.communicationsStatus = communicationsStatus + self.orbitalParametersStatus = orbitalParametersStatus + self.overallStatus = overallStatus + self.timestamp = timestamp + } +} + +struct OrbitCorrectionResult: Codable, Sendable { + var success: Bool + var deltaV: Double // km/s + var newAltitude: Double // km + var fuelUsed: Double // kg + var timestamp: Date + + init(success: Bool, deltaV: Double, newAltitude: Double, fuelUsed: Double, timestamp: Date) { + self.success = success + self.deltaV = deltaV + self.newAltitude = newAltitude + self.fuelUsed = fuelUsed + self.timestamp = timestamp + } +} + +struct MissionReport: Codable, Sendable { + var reportId: String + var generatedAt: Date + var telemetrySummary: String + var crewSummary: String + var healthSummary: String + + init(reportId: String, generatedAt: Date, telemetrySummary: String, crewSummary: String, healthSummary: String) { + self.reportId = reportId + self.generatedAt = generatedAt + self.telemetrySummary = telemetrySummary + self.crewSummary = crewSummary + self.healthSummary = healthSummary + } +} + +struct MissionOperationResult: Codable, Sendable { + var operation: OperationType + var status: OperationStatus + var telemetryData: TelemetryData? + var crewStatus: CrewStatus? + var healthCheck: HealthCheckResult? + var orbitCorrection: OrbitCorrectionResult? + var report: MissionReport? + var duration: TimeInterval + var timestamp: Date + var errorMessage: String? + + init( + operation: OperationType, + status: OperationStatus, + telemetryData: TelemetryData? = nil, + crewStatus: CrewStatus? = nil, + healthCheck: HealthCheckResult? = nil, + orbitCorrection: OrbitCorrectionResult? = nil, + report: MissionReport? = nil, + duration: TimeInterval, + timestamp: Date, + errorMessage: String? = nil + ) { + self.operation = operation + self.status = status + self.telemetryData = telemetryData + self.crewStatus = crewStatus + self.healthCheck = healthCheck + self.orbitCorrection = orbitCorrection + self.report = report + self.duration = duration + self.timestamp = timestamp + self.errorMessage = errorMessage + } +} diff --git a/Examples/Schedule/README.md b/Examples/Schedule/README.md new file mode 100644 index 0000000..8600356 --- /dev/null +++ b/Examples/Schedule/README.md @@ -0,0 +1,160 @@ +# Schedule + +Demonstrates Temporal's scheduling capabilities through a space mission control automation scenario using real NASA and space APIs to monitor the International Space Station. + +## Features + +This example monitors the International Space Station (ISS) with scheduled operations that execute real API calls to fetch live telemetry and crew data. + +**Calendar-Based Scheduling:** +- System health check - Daily at 00:00 UTC +- Crew status verification - 3x daily (06:00, 14:00, 22:00 UTC) + +**Interval-Based Scheduling:** +- Telemetry collection - Every 90 minutes (matches ISS orbital period) + +**Real API Integration:** +- wheretheiss.at - Real-time ISS position, altitude, velocity, visibility + - Endpoint: `https://api.wheretheiss.at/v1/satellites/25544` + - Returns: Real-time ISS position, altitude (417 km), velocity (27,600 km/h), visibility + - Rate limit: ~1 request per second +- open-notify.org - Current astronauts in space and their spacecraft + - Endpoint: `http://api.open-notify.org/astros.json` + - Returns: Current astronauts in space (shows both ISS and Tiangong crew) + - Rate limit: ~1 request per 5 seconds +- Both APIs are free and require no registration. + + + +**Durable Execution:** +- Automatic retries with exponential backoff for network failures +- Timeout handling for API calls +- Workflow orchestration ensures reliable operation execution +- Operations continue despite worker restarts +- Complete audit trail of all operations in Temporal UI + +**Reliable Scheduling:** +- Never miss orbital windows or system checks +- Precise timing with calendar and interval specifications + +**Automatic Retries:** +- Handle API timeouts and network failures gracefully +- Exponential backoff prevents overwhelming APIs + +**Long-Running Operations:** +- Support for multi-minute operations (e.g., thruster burns) +- Workflow sleep for accurate timing + + + + + +## Usage + +Start Temporal server: +```bash +temporal server start-dev +``` + +Run the example: +```bash +cd Examples/Schedule +swift run ScheduleExample +``` + +View schedules in Temporal UI: `http://localhost:8233/schedules` + +The example creates three schedules and triggers them immediately for demonstration: +1. Telemetry collection - Shows real ISS position changing over time +2. Crew status check - Lists current astronauts on ISS and other stations +3. System health check - Combines real telemetry with simulated subsystem status + +**Stopping the example:** +Press `Ctrl+C` to stop. The schedules will remain active until manually deleted. + +**Cleaning up schedules:** +If you need to manually delete the schedules (e.g., if the example was interrupted): +```bash +temporal schedule delete --schedule-id iss-telemetry-schedule +temporal schedule delete --schedule-id iss-crew-schedule +temporal schedule delete --schedule-id iss-health-schedule +``` + +Or delete all at once: +```bash +temporal schedule delete --schedule-id iss-telemetry-schedule && \ +temporal schedule delete --schedule-id iss-crew-schedule && \ +temporal schedule delete --schedule-id iss-health-schedule +``` + +## ISS Facts + +The example uses real data from: +- Altitude: ~408 km (varies 400-420 km) +- Velocity: ~27,600 km/h (7.66 km/s) +- Orbital period: ~90 minutes (16 orbits per day) +- NORAD Catalog ID: 25544 +- Current crew: 9 astronauts (as of example data) + +## Key Patterns + +**Interval-based schedule (every 90 minutes):** +```swift +Schedule( + action: .startWorkflow(...), + specification: .init( + intervals: [.init( + every: .seconds(90 * 60), + offset: .zero + )], + timeZoneName: "UTC" + ) +) +``` + +**Calendar-based schedule (specific times):** +```swift +Schedule( + action: .startWorkflow(...), + specification: .init( + calendars: [ + .init(hour: [.init(value: 6)], minute: [.init(value: 0)]), + .init(hour: [.init(value: 14)], minute: [.init(value: 0)]) + ], + timeZoneName: "UTC" + ) +) +``` + +**Creating schedule with immediate trigger:** +```swift +let handle = try await client.createSchedule( + schedule: telemetrySchedule, + options: .init( + id: "iss-telemetry-schedule", + triggerImmediately: true + ) +) +``` + +**Activity retry policy for API calls:** +```swift +ActivityOptions( + startToCloseTimeout: .seconds(30), + retryPolicy: RetryPolicy( + maximumAttempts: 3, + initialInterval: .seconds(1), + backoffCoefficient: 2.0 + ) +) +``` + +## Output + +The example displays: +- Real-time ISS telemetry with formatted position and altitude status +- Complete list of astronauts currently in space (by station) +- System health assessment based on real orbital parameters +- Links to Temporal UI for viewing schedules and workflow executions + +View all schedules in Temporal UI: `http://localhost:8233/schedules` diff --git a/Examples/Schedule/ScheduleExample.swift b/Examples/Schedule/ScheduleExample.swift new file mode 100644 index 0000000..e335757 --- /dev/null +++ b/Examples/Schedule/ScheduleExample.swift @@ -0,0 +1,465 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Temporal SDK open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift Temporal SDK project authors +// Licensed under MIT License +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Temporal SDK project authors +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +#if GRPCNIOTransport +import Foundation +import GRPCNIOTransportHTTP2Posix +import Logging +import Temporal + +/// Space Mission Schedule Example. +/// +/// This example demonstrates Temporal's scheduling capabilities through a space mission +/// control automation scenario that monitors the International Space Station (ISS). +/// +/// **Features Demonstrated:** +/// - **Calendar-Based Scheduling**: Daily and time-specific operations (system health at 00:00 UTC, crew checks 3x daily) +/// - **Interval-Based Scheduling**: Periodic operations (telemetry every 90 minutes matching ISS orbital period) +/// - **Real API Integration**: Activities fetch live data from NASA/space APIs (wheretheiss.at, open-notify.org) +/// - **Durable Execution**: Automatic retries for network failures, timeout handling +/// - **Schedule Management**: Creating, triggering, and managing multiple schedules +/// +/// **Real APIs Used:** +/// - wheretheiss.at - Real-time ISS position, altitude, velocity (no auth required) +/// - open-notify.org - Current astronauts in space (no auth required) +/// +/// The example showcases why Temporal's durable execution is essential for mission-critical +/// systems that depend on external APIs and require reliable scheduled operations. +@main +struct ScheduleExample { + static func main() async throws { + var logger = Logger(label: "TemporalWorker") + logger.logLevel = .info + + let namespace = "default" + let taskQueue = "iss-mission-control" + + // Confirm no prior schedules exist from previous runs + try await performCleanup(logger: logger) + + print("🚀 Space Mission Control - Real-time ISS Monitoring with Temporal") + print(String(repeating: "=", count: 70)) + print("Mission: ISS Operations Monitor") + print("ISS Mission Time: T+9,847 days (since Nov 20, 1998)") + print("NORAD ID: 25544") + print() + print("🔗 View all workflows: http://localhost:8233/namespaces/\(namespace)/workflows") + print("🔗 View all schedules: http://localhost:8233/schedules") + print() + + print("📡 Initializing Mission Control Systems...") + + // Create worker configuration + let workerConfiguration = TemporalWorker.Configuration( + namespace: namespace, + taskQueue: taskQueue, + instrumentation: .init(serverHostname: "localhost") + ) + + // Create the worker + let worker = try TemporalWorker( + configuration: workerConfiguration, + target: .ipv4(address: "127.0.0.1", port: 7233), + transportSecurity: .plaintext, + activityContainers: SpaceMissionActivities(), + activities: [], + workflows: [SpaceMissionWorkflow.self], + logger: logger + ) + + let client = try TemporalClient( + target: .ipv4(address: "127.0.0.1", port: 7233), + transportSecurity: .plaintext, + configuration: .init( + instrumentation: .init( + serverHostname: "localhost" + ) + ), + logger: logger + ) + + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await worker.run() + } + + group.addTask { + try await client.run() + } + + // Wait for worker and client to initialize + try await Task.sleep(for: .seconds(1)) + + print(" ✅ Connected to ISS tracking API (wheretheiss.at)") + print(" ✅ Connected to crew roster API (open-notify.org)") + print(" ✅ Temporal client connected (localhost:7233)") + print(" ✅ Worker started on task queue: \(taskQueue)") + print() + + print("📅 Creating Mission Schedules...") + + // Schedule 1: Real-time Telemetry Collection (every 90 minutes - ISS orbital period) + let telemetrySchedule = Schedule( + action: .startWorkflow( + .init( + workflowName: "\(SpaceMissionWorkflow.self)", + options: .init( + id: "telemetry-\(UUID().uuidString)", + taskQueue: taskQueue + ), + input: MissionOperationInput(operation: .collectTelemetry) + ) + ), + specification: .init( + intervals: [ + .init( + every: .seconds(90 * 60), // 90 minutes + offset: .zero + ) + ], + timeZoneName: "UTC" + ) + ) + + let telemetryHandle = try await client.createSchedule( + id: "iss-telemetry-schedule", + schedule: telemetrySchedule, + options: .init( + triggerImmediately: true + ) + ) + + print(" ✅ Real-time Telemetry (every 90 min - orbital period)") + print(" Schedule ID: iss-telemetry-schedule") + + // Schedule 2: Crew Status Check (3x daily at 06:00, 14:00, 22:00 UTC) + let crewSchedule = Schedule( + action: .startWorkflow( + .init( + workflowName: "\(SpaceMissionWorkflow.self)", + options: .init( + id: "crew-\(UUID().uuidString)", + taskQueue: taskQueue + ), + input: MissionOperationInput(operation: .checkCrew) + ) + ), + specification: .init( + calendars: [ + .init(minute: [.init(value: 0)], hour: [.init(value: 6)]), + .init(minute: [.init(value: 0)], hour: [.init(value: 14)]), + .init(minute: [.init(value: 0)], hour: [.init(value: 22)]), + ], + timeZoneName: "UTC" + ) + ) + + let crewHandle = try await client.createSchedule( + id: "iss-crew-schedule", + schedule: crewSchedule, + options: .init( + triggerImmediately: true + ) + ) + + print(" ✅ Crew Status Check (3x daily: 06:00, 14:00, 22:00 UTC)") + print(" Schedule ID: iss-crew-schedule") + + // Schedule 3: System Health Check (daily at 00:00 UTC) + let healthSchedule = Schedule( + action: .startWorkflow( + .init( + workflowName: "\(SpaceMissionWorkflow.self)", + options: .init( + id: "health-\(UUID().uuidString)", + taskQueue: taskQueue + ), + input: MissionOperationInput(operation: .systemHealth) + ) + ), + specification: .init( + calendars: [ + .init( + minute: [.init(value: 0)], + hour: [.init(value: 0)] + ) + ], + timeZoneName: "UTC" + ) + ) + + let healthHandle = try await client.createSchedule( + id: "iss-health-schedule", + schedule: healthSchedule, + options: .init( + triggerImmediately: true + ) + ) + + print(" ✅ System Health Check (daily at 00:00 UTC)") + print(" Schedule ID: iss-health-schedule") + print() + + print(String(repeating: "=", count: 70)) + print() + print("🛰️ Executing Scheduled Operations...") + print() + + // Wait for scheduled workflows to execute + try await Task.sleep(for: .seconds(3)) + + // Describe schedules to get recent actions + let telemetryDesc = try await telemetryHandle.describe(inputType: MissionOperationInput.self) + let crewDesc = try await crewHandle.describe(inputType: MissionOperationInput.self) + let healthDesc = try await healthHandle.describe(inputType: MissionOperationInput.self) + + // Display results from triggered workflows + var operationCount = 0 + + // Process telemetry result + if let workflowId = telemetryDesc.info.recentActions.first?.action.workflowId { + operationCount += 1 + print("[Operation \(operationCount)] 🌍 Real-time Telemetry Collection") + print(String(repeating: "-", count: 70)) + + do { + let handle = client.workflowHandle( + type: SpaceMissionWorkflow.self, + id: workflowId + ) + let result = try await handle.result() + + if let telemetry = result.telemetryData { + print(" 📍 Position: \(telemetry.formattedPosition)") + print(" 🚀 Altitude: \(String(format: "%.2f", telemetry.altitude)) km (\(telemetry.altitudeStatus) range: 400-420 km)") + print( + " ⚡ Velocity: \(String(format: "%.2f", telemetry.velocity)) km/h (\(String(format: "%.2f", telemetry.velocity / 3600)) km/s)" + ) + print(" ☀️ Visibility: \(telemetry.visibility.capitalized)") + print(" 📡 Footprint: \(String(format: "%.2f", telemetry.footprint)) km diameter") + + let date = Date(timeIntervalSince1970: TimeInterval(telemetry.timestamp)) + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + formatter.timeZone = TimeZone(identifier: "UTC") + print(" 🕐 Data timestamp: \(formatter.string(from: date)) UTC") + } + + print(" ✅ Status: \(result.status.rawValue.uppercased())") + print(" ⏱️ Duration: \(String(format: "%.1f", result.duration))s") + print() + print(" 🔗 View workflow: http://localhost:8233/namespaces/\(namespace)/workflows/\(workflowId)") + print() + } catch { + print(" ❌ Failed to retrieve workflow result: \(error)") + print() + } + } + + // Process crew result + if let workflowId = crewDesc.info.recentActions.first?.action.workflowId { + operationCount += 1 + print("[Operation \(operationCount)] 👨‍🚀 Crew Status Check") + print(String(repeating: "-", count: 70)) + + do { + let handle = client.workflowHandle( + type: SpaceMissionWorkflow.self, + id: workflowId + ) + let result = try await handle.result() + + if let crew = result.crewStatus { + print(" 🌐 Total people in space: \(crew.totalInSpace)") + print() + print(" 🛸 ISS Crew (\(crew.issCrewCount) astronauts):") + for member in crew.issCrewMembers { + print(" • \(member)") + } + + if !crew.otherStations.isEmpty { + print() + print(" 🛸 Other Stations:") + for (station, members) in crew.otherStations.sorted(by: { $0.key < $1.key }) { + print(" \(station) (\(members.count) astronauts):") + for member in members { + print(" • \(member)") + } + } + } + } + + print() + print(" ✅ Status: \(result.status.rawValue.uppercased())") + print(" ⏱️ Duration: \(String(format: "%.1f", result.duration))s") + print() + print(" 🔗 View workflow: http://localhost:8233/namespaces/\(namespace)/workflows/\(workflowId)") + print() + } catch { + print(" ❌ Failed to retrieve workflow result: \(error)") + print() + } + } + + // Process health result + if let workflowId = healthDesc.info.recentActions.first?.action.workflowId { + operationCount += 1 + print("[Operation \(operationCount)] 🏥 System Health Check") + print(String(repeating: "-", count: 70)) + + do { + let handle = client.workflowHandle( + type: SpaceMissionWorkflow.self, + id: workflowId + ) + let result = try await handle.result() + + if let health = result.healthCheck, let telemetry = result.telemetryData { + print(" Based on real telemetry from altitude: \(String(format: "%.2f", telemetry.altitude)) km") + print() + print(" 💾 Data Storage: \(String(format: "%.1f", health.dataStorageAvailable))% available") + print(" 🔋 Power Systems: \(health.powerSystemsStatus)") + print(" 🌡️ Thermal Control: \(String(format: "%.1f", health.thermalControlTemp))°C (Nominal)") + print(" 📡 Communications: \(health.communicationsStatus)") + print(" 🛰️ Orbital Parameters: \(health.orbitalParametersStatus)") + print(" - Altitude: \(String(format: "%.2f", telemetry.altitude)) km (target: 408±10 km)") + print(" - Velocity: \(String(format: "%.2f", telemetry.velocity)) km/h (target: ~27,600 km/h)") + print() + print(" ✅ Status: \(health.overallStatus)") + } else if let health = result.healthCheck { + print(" 💾 Data Storage: \(String(format: "%.1f", health.dataStorageAvailable))% available") + print(" 🔋 Power Systems: \(health.powerSystemsStatus)") + print(" 🌡️ Thermal Control: \(String(format: "%.1f", health.thermalControlTemp))°C") + print(" 📡 Communications: \(health.communicationsStatus)") + print(" 🛰️ Orbital Parameters: \(health.orbitalParametersStatus)") + print() + print(" ✅ Status: \(health.overallStatus)") + } + + print(" ⏱️ Duration: \(String(format: "%.1f", result.duration))s") + print() + print(" 🔗 View workflow: http://localhost:8233/namespaces/\(namespace)/workflows/\(workflowId)") + print() + } catch { + print(" ❌ Failed to retrieve workflow result: \(error)") + print() + } + } + + print(String(repeating: "=", count: 70)) + print() + print("📊 Mission Control Dashboard") + print(String(repeating: "-", count: 70)) + print(" Active Schedules: 3") + print() + print(" Next Scheduled Operations:") + print(" • Telemetry Collection: in 90 minutes (next orbit)") + print(" • Crew Status Check: at next scheduled time (06:00, 14:00, or 22:00 UTC)") + print(" • System Health: at 00:00 UTC daily") + print() + print(" View all schedules: http://localhost:8233/schedules") + print(" View workflows: http://localhost:8233/namespaces/\(namespace)/workflows") + print() + + print("✅ All mission schedules active and operational") + print() + print("Schedules will remain active until you remove them") + print("or restart the Temporal Dev Server.") + print("Monitor them in the Temporal UI at the links above.") + print() + print("Note: To delete schedules after stopping, run:") + print(" temporal schedule delete --schedule-id iss-telemetry-schedule") + print(" temporal schedule delete --schedule-id iss-crew-schedule") + print(" temporal schedule delete --schedule-id iss-health-schedule") + print() + + // Keep running until user interrupts + // The worker and client will continue processing scheduled workflows + // When the user presses Ctrl+C, the task group will be cancelled + // Note: Schedules persist in Temporal and must be manually deleted + // Using a very large but safe duration (24 hours) + try await Task.sleep(for: .seconds(86400)) + } + } + + /// Performs pre-flight cleanup of existing schedules to ensure clean state. + static func performCleanup(logger: Logger) async throws { + print("🧹 Performing pre-flight cleanup...") + + let client = try TemporalClient( + target: .ipv4(address: "127.0.0.1", port: 7233), + transportSecurity: .plaintext, + configuration: .init( + instrumentation: .init(serverHostname: "localhost") + ), + logger: logger + ) + + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await client.run() + } + + // Wait for client to initialize + try await Task.sleep(for: .milliseconds(500)) + + // List of schedule IDs to clean up + let scheduleIds = [ + "iss-telemetry-schedule", + "iss-crew-schedule", + "iss-health-schedule", + ] + + var deletedCount = 0 + var notFoundCount = 0 + + // Attempt to delete each schedule if it exists + for scheduleId in scheduleIds { + do { + let handle = client.untypedScheduleHandle(id: scheduleId) + try await handle.delete() + print(" ✅ Deleted existing schedule: \(scheduleId)") + deletedCount += 1 + } catch { + // Schedule doesn't exist or already deleted - this is fine + let errorDescription = String(describing: error) + if errorDescription.contains("NOT_FOUND") || errorDescription.contains("not found") { + print(" ℹ️ Schedule not found (already clean): \(scheduleId)") + notFoundCount += 1 + } else { + print(" ⚠️ Could not delete schedule \(scheduleId): \(error)") + } + } + } + + if deletedCount > 0 { + print(" ✅ Cleanup complete: deleted \(deletedCount) schedule(s)") + } else { + print(" ✅ Cleanup complete: all schedules already clean") + } + print() + + // Cancel the client + group.cancelAll() + } + } +} +#else +@main +struct ScheduleExample { + static func main() async throws { + fatalError("GRPCNIOTransport trait disabled") + } +} +#endif diff --git a/Examples/Schedule/Workflow.swift b/Examples/Schedule/Workflow.swift new file mode 100644 index 0000000..f82fce3 --- /dev/null +++ b/Examples/Schedule/Workflow.swift @@ -0,0 +1,145 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Temporal SDK open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift Temporal SDK project authors +// Licensed under MIT License +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Temporal SDK project authors +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import Foundation +import Temporal + +@Workflow +final class SpaceMissionWorkflow { + func run(input: MissionOperationInput) async throws -> MissionOperationResult { + let startTime = Date() + + let activityOptions = ActivityOptions( + startToCloseTimeout: .seconds(30), + retryPolicy: RetryPolicy( + initialInterval: .seconds(1), + maximumAttempts: 3 + ) + ) + + do { + switch input.operation { + case .collectTelemetry: + let telemetry = try await Workflow.executeActivity( + SpaceMissionActivities.Activities.CollectRealTelemetry.self, + options: activityOptions, + input: TelemetryRequest() + ) + return MissionOperationResult( + operation: .collectTelemetry, + status: .success, + telemetryData: telemetry, + duration: Date().timeIntervalSince(startTime), + timestamp: Date() + ) + + case .checkCrew: + let crew = try await Workflow.executeActivity( + SpaceMissionActivities.Activities.CheckCrewStatus.self, + options: activityOptions, + input: CrewStatusRequest(filterCraft: "ISS") + ) + return MissionOperationResult( + operation: .checkCrew, + status: .success, + crewStatus: crew, + duration: Date().timeIntervalSince(startTime), + timestamp: Date() + ) + + case .systemHealth: + let health = try await Workflow.executeActivity( + SpaceMissionActivities.Activities.PerformSystemHealthCheck.self, + options: activityOptions, + input: HealthCheckRequest(priority: input.priority) + ) + return MissionOperationResult( + operation: .systemHealth, + status: .success, + healthCheck: health, + duration: Date().timeIntervalSince(startTime), + timestamp: Date() + ) + + case .orbitCorrection: + // First get current telemetry + let telemetry = try await Workflow.executeActivity( + SpaceMissionActivities.Activities.CollectRealTelemetry.self, + options: activityOptions, + input: TelemetryRequest() + ) + + // Determine if correction needed + let targetAltitude = 410.0 // km + let needsCorrection = abs(telemetry.altitude - targetAltitude) > 5.0 + + guard needsCorrection else { + return MissionOperationResult( + operation: .orbitCorrection, + status: .success, + telemetryData: telemetry, + duration: Date().timeIntervalSince(startTime), + timestamp: Date(), + errorMessage: "No correction needed, altitude within tolerance" + ) + } + // Simulate thruster burn duration + let burnDuration = 45 + try await Workflow.sleep(for: .seconds(burnDuration)) + + // Execute correction + let correction = try await Workflow.executeActivity( + SpaceMissionActivities.Activities.ExecuteOrbitCorrection.self, + options: activityOptions, + input: OrbitCorrectionInput( + currentAltitude: telemetry.altitude, + targetAltitude: targetAltitude, + burnDuration: burnDuration + ) + ) + + return MissionOperationResult( + operation: .orbitCorrection, + status: .success, + telemetryData: telemetry, + orbitCorrection: correction, + duration: Date().timeIntervalSince(startTime), + timestamp: Date() + ) + + case .generateReport: + let report = try await Workflow.executeActivity( + SpaceMissionActivities.Activities.GenerateMissionReport.self, + options: activityOptions, + input: ReportRequest(includeHistory: false) + ) + return MissionOperationResult( + operation: .generateReport, + status: .success, + report: report, + duration: Date().timeIntervalSince(startTime), + timestamp: Date() + ) + } + } catch { + return MissionOperationResult( + operation: input.operation, + status: .failed, + duration: Date().timeIntervalSince(startTime), + timestamp: Date(), + errorMessage: error.localizedDescription + ) + } + } +} diff --git a/Examples/Signals/README.md b/Examples/Signals/README.md new file mode 100644 index 0000000..27f8446 --- /dev/null +++ b/Examples/Signals/README.md @@ -0,0 +1,59 @@ +# Signals, Queries, and Updates + +Demonstrates Temporal's message passing capabilities through an order processing workflow. + +## Features + +**Signals** - Asynchronous, no return value +- `pause()` - Pause workflow execution +- `resume()` - Resume paused workflow +- `cancel()` - Cancel workflow + +**Queries** - Synchronous, read-only +- `getStatus()` - Get current workflow state and progress + +**Updates** - Synchronous, mutates and returns +- `setPriority()` - Change priority with validation + +## Usage + +Start Temporal server: +```bash +temporal server start-dev +``` + +Run the example: +```bash +cd Examples/Signals +swift run SignalExample +``` + +The example demonstrates: +1. Start workflow and query initial status +2. Update priority to "expedited" +3. Pause workflow with signal +4. Query to confirm paused state +5. Resume workflow with signal +6. Query to confirm resumed state +7. Workflow completes + +## Key Patterns + +**Waiting for signals with `Workflow.condition`:** +```swift +try await Workflow.condition { !self.isPaused || self.isCancelled } +``` + +**Update validation:** +```swift +@WorkflowUpdate +func setPriority(input: SetPriorityInput) async throws -> String { + guard validPriorities.contains(input.priority) else { + throw ApplicationError(...) + } + priority = input.priority + return "Priority changed" +} +``` + +View workflow in Temporal UI: `http://localhost:8233` diff --git a/Examples/Signals/SignalActivities.swift b/Examples/Signals/SignalActivities.swift new file mode 100644 index 0000000..6e3ffec --- /dev/null +++ b/Examples/Signals/SignalActivities.swift @@ -0,0 +1,61 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Temporal SDK open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift Temporal SDK project authors +// Licensed under MIT License +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Temporal SDK project authors +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import Foundation +import Temporal + +/// Activities for order processing workflow demonstrating external system interactions. +@ActivityContainer +struct SignalActivities { + // MARK: - Activity Input Types + + struct ProcessOrderInput: Codable { + let orderId: String + let items: [String] + } + + struct ShipOrderInput: Codable { + let orderId: String + let priority: String + } + + // MARK: - Activities + + /// Processes an order. + @Activity + func processOrder(input: ProcessOrderInput) async throws -> String { + print("📦 Processing order \(input.orderId) with \(input.items.count) item(s)...") + try await Task.sleep(for: .seconds(2)) + print("✅ Order processed") + return "PROCESSED-\(input.orderId)" + } + + /// Ships an order. + @Activity + func shipOrder(input: ShipOrderInput) async throws -> String { + print("🚚 Shipping order \(input.orderId) with \(input.priority) priority...") + try await Task.sleep(for: .seconds(2)) + let trackingNumber = "TRACK-\(UUID().uuidString.prefix(8))" + print("✅ Order shipped: \(trackingNumber)") + return trackingNumber + } + + /// Notifies customer. + @Activity + func notifyCustomer(input: String) async throws { + print("📧 Notifying customer: \(input)") + try await Task.sleep(for: .milliseconds(500)) + print("✅ Notification sent") + } +} diff --git a/Examples/Signals/SignalExample.swift b/Examples/Signals/SignalExample.swift new file mode 100644 index 0000000..a62ec9c --- /dev/null +++ b/Examples/Signals/SignalExample.swift @@ -0,0 +1,234 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Temporal SDK open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift Temporal SDK project authors +// Licensed under MIT License +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Temporal SDK project authors +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +#if GRPCNIOTransport +import Foundation +import GRPCNIOTransportHTTP2Posix +import Logging +import Temporal + +/// Signal, Query, and Update Example. +/// +/// This example demonstrates Temporal's message passing capabilities through a realistic. +/// order processing scenario. It showcases: +/// +/// **Signals** - Asynchronous messages that mutate workflow state: +/// - `pause()` - Pauses order processing +/// - `resume()` - Resumes a paused order +/// - `cancel()` - Cancels the order +/// +/// **Queries** - Synchronous read-only operations to inspect workflow state: +/// - `getStatus()` - Returns current order status, state, and progress +/// +/// **Updates** - Synchronous operations that both mutate and return values: +/// - `setPriority()` - Changes order priority with validation +/// +/// The example demonstrates:. +/// - How to use Workflow.condition to wait for signals +/// - Proper validation in update handlers +/// - State management across signals, queries, and updates +/// - Clean separation between workflow logic and external communication +@main +struct SignalExample { + static func main() async throws { + let logger = Logger(label: "TemporalWorker") + + let namespace = "default" + let taskQueue = "signal-queue" + + // Create worker configuration + let workerConfiguration = TemporalWorker.Configuration( + namespace: namespace, + taskQueue: taskQueue, + instrumentation: .init(serverHostname: "localhost") + ) + + // Create the worker + let worker = try TemporalWorker( + configuration: workerConfiguration, + target: .ipv4(address: "127.0.0.1", port: 7233), + transportSecurity: .plaintext, + activityContainers: SignalActivities(), + activities: [], + workflows: [SignalWorkflow.self], + logger: logger + ) + + let client = try TemporalClient( + target: .ipv4(address: "127.0.0.1", port: 7233), + transportSecurity: .plaintext, + configuration: .init( + instrumentation: .init( + serverHostname: "localhost" + ) + ), + logger: logger + ) + + try await withThrowingTaskGroup { group in + group.addTask { + try await worker.run() + } + + group.addTask { + try await client.run() + } + + // Wait for the worker and client to initialize + try await Task.sleep(for: .seconds(1)) + + print("🔔 Signal, Query, and Update Example") + print(String(repeating: "=", count: 60)) + print() + + let orderInput = SignalWorkflow.OrderInput( + orderId: "ORDER-12345", + customerId: "customer-001", + items: ["Widget A", "Widget B", "Widget C"] + ) + + let workflowId = "order-processing-" + UUID().uuidString + print("🔗 View in Temporal UI:") + print(" http://localhost:8233/namespaces/\(namespace)/workflows/\(workflowId)") + print() + + // Start workflow asynchronously + let handle = try await client.startWorkflow( + type: SignalWorkflow.self, + options: .init(id: workflowId, taskQueue: taskQueue), + input: orderInput + ) + + print("✅ Workflow started: \(workflowId)") + print() + + // Wait a moment for workflow to start processing + try await Task.sleep(for: .seconds(1)) + + // Query 1: Check initial status + print("📊 Query: Getting initial status...") + let status1 = try await handle.query(queryType: SignalWorkflow.GetStatus.self) + print(" Status: \(status1.currentState)") + print(" Completed steps: \(status1.completedSteps)") + print() + + // Update: Change priority to expedited + print("🔄 Update: Changing priority to expedited...") + do { + let updateResult = try await handle.executeUpdate( + updateType: SignalWorkflow.SetPriority.self, + input: SignalWorkflow.SetPriorityInput(priority: "expedited") + ) + print(" ✅ \(updateResult)") + } catch { + print(" ❌ Update failed: \(error)") + } + print() + + // Wait for order processing to complete + try await Task.sleep(for: .seconds(2)) + + // Signal 1: Pause the workflow + print("⏸️ Signal: Pausing workflow...") + try await handle.signal(signalType: SignalWorkflow.Pause.self) + print(" ✅ Pause signal sent") + print() + + // Query 2: Verify workflow is paused + try await Task.sleep(for: .milliseconds(500)) + print("📊 Query: Checking if workflow is paused...") + let status2 = try await handle.query(queryType: SignalWorkflow.GetStatus.self) + print(" Is paused: \(status2.isPaused)") + print(" Current state: \(status2.currentState)") + print(" Completed steps: \(status2.completedSteps)") + print() + + // Try to update priority while paused (should still work if not shipping yet) + print("🔄 Update: Trying to change priority while paused...") + do { + let updateResult = try await handle.executeUpdate( + updateType: SignalWorkflow.SetPriority.self, + input: SignalWorkflow.SetPriorityInput(priority: "overnight") + ) + print(" ✅ \(updateResult)") + } catch { + print(" ❌ Update failed (expected if already shipping): \(error.localizedDescription)") + } + print() + + // Wait while paused + print("⏳ Waiting 2 seconds while workflow is paused...") + try await Task.sleep(for: .seconds(2)) + + // Query 3: Confirm still paused + print("📊 Query: Confirming workflow is still paused...") + let status3 = try await handle.query(queryType: SignalWorkflow.GetStatus.self) + print(" Is paused: \(status3.isPaused)") + print(" Current state: \(status3.currentState)") + print() + + // Signal 2: Resume the workflow + print("▶️ Signal: Resuming workflow...") + try await handle.signal(signalType: SignalWorkflow.Resume.self) + print(" ✅ Resume signal sent") + print() + + // Query 4: Verify workflow is resumed + try await Task.sleep(for: .milliseconds(500)) + print("📊 Query: Checking if workflow is resumed...") + let status4 = try await handle.query(queryType: SignalWorkflow.GetStatus.self) + print(" Is paused: \(status4.isPaused)") + print(" Current state: \(status4.currentState)") + print() + + // Wait for workflow to complete + print("⏳ Waiting for workflow to complete...") + let result = try await handle.result() + + print() + print(String(repeating: "=", count: 60)) + print("✅ Workflow Completed!") + print(String(repeating: "=", count: 60)) + print("Order ID: \(result.orderId)") + print("Status: \(result.status)") + print("Priority: \(result.priority)") + if let processedId = result.processedId { + print("Processed ID: \(processedId)") + } + if let trackingNumber = result.trackingNumber { + print("Tracking Number: \(trackingNumber)") + } + print() + + print(String(repeating: "=", count: 60)) + print("Example completed! Demonstrated:") + print("- ⏸️ Pause signal") + print("- ▶️ Resume signal") + print("- 📊 Status queries") + print("- 🔄 Priority updates with validation") + print(String(repeating: "=", count: 60)) + + // Cancel the client and worker + group.cancelAll() + } + } +} +#else +@main +struct SignalExample { + static func main() async throws { + fatalError("GRPCNIOTransport trait disabled") + } +} +#endif diff --git a/Examples/Signals/SignalWorkflow.swift b/Examples/Signals/SignalWorkflow.swift new file mode 100644 index 0000000..fad2fc1 --- /dev/null +++ b/Examples/Signals/SignalWorkflow.swift @@ -0,0 +1,226 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Temporal SDK open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift Temporal SDK project authors +// Licensed under MIT License +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Temporal SDK project authors +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import Temporal + +/// Demonstrates signals, queries, and updates in a realistic order processing workflow. +/// +/// This workflow shows:. +/// - Using signals to control workflow execution (pause/resume/cancel) +/// - Using queries to inspect workflow state without mutation +/// - Using updates to modify workflow state synchronously with validation +/// - Waiting for conditions with Workflow.condition +@Workflow +final class SignalWorkflow { + // MARK: - Input/Output Types + + struct OrderInput: Codable { + let orderId: String + let customerId: String + let items: [String] + } + + struct OrderOutput: Codable { + let orderId: String + let status: String + let processedId: String? + let trackingNumber: String? + let priority: String + } + + // MARK: - Signal Input Types + + struct SetPriorityInput: Codable { + let priority: String + } + + // MARK: - Query Output Types + + struct OrderStatus: Codable { + let orderId: String + let currentState: String + let isPaused: Bool + let isCancelled: Bool + let priority: String + let completedSteps: [String] + } + + // MARK: - Workflow State + + private var currentState: String = "pending" + private var isPaused: Bool = false + private var isCancelled: Bool = false + private var priority: String = "standard" + private var completedSteps: [String] = [] + private let orderId: String + + init(input: OrderInput) { + self.orderId = input.orderId + } + + // MARK: - Workflow Implementation + + func run(input: OrderInput) async throws -> OrderOutput { + currentState = "processing" + + // Step 1: Process the order + completedSteps.append("started") + + // Wait if paused + try await waitIfPaused() + if isCancelled { + return cancelledOutput() + } + + currentState = "processing_order" + let processedId = try await Workflow.executeActivity( + SignalActivities.Activities.ProcessOrder.self, + options: .init(startToCloseTimeout: .seconds(30)), + input: SignalActivities.ProcessOrderInput( + orderId: input.orderId, + items: input.items + ) + ) + completedSteps.append("order_processed") + + // Wait if paused + try await waitIfPaused() + if isCancelled { + return cancelledOutput() + } + + // Step 2: Ship the order + currentState = "shipping" + let trackingNumber = try await Workflow.executeActivity( + SignalActivities.Activities.ShipOrder.self, + options: .init(startToCloseTimeout: .seconds(30)), + input: SignalActivities.ShipOrderInput( + orderId: input.orderId, + priority: priority + ) + ) + completedSteps.append("order_shipped") + + // Wait if paused + try await waitIfPaused() + if isCancelled { + return cancelledOutput() + } + + // Step 3: Notify customer + currentState = "notifying" + try await Workflow.executeActivity( + SignalActivities.Activities.NotifyCustomer.self, + options: .init(startToCloseTimeout: .seconds(30)), + input: "Your order \(input.orderId) has shipped! Tracking: \(trackingNumber)" + ) + completedSteps.append("customer_notified") + + currentState = "completed" + return OrderOutput( + orderId: input.orderId, + status: "completed", + processedId: processedId, + trackingNumber: trackingNumber, + priority: priority + ) + } + + // MARK: - Signal Handlers + + /// Pauses the workflow execution. + @WorkflowSignal + func pause(input: Void) async throws { + isPaused = true + } + + /// Resumes the workflow execution. + @WorkflowSignal + func resume(input: Void) async throws { + isPaused = false + } + + /// Cancels the workflow. + @WorkflowSignal + func cancel(input: Void) async throws { + isCancelled = true + isPaused = false // Unpause if paused to allow cancellation to proceed + } + + // MARK: - Query Handlers + + /// Returns the current status of the order. + @WorkflowQuery + func getStatus(input: Void) throws -> OrderStatus { + return OrderStatus( + orderId: orderId, + currentState: currentState, + isPaused: isPaused, + isCancelled: isCancelled, + priority: priority, + completedSteps: completedSteps + ) + } + + // MARK: - Update Handlers + + /// Updates the priority of the order with validation. + @WorkflowUpdate + func setPriority(input: SetPriorityInput) async throws -> String { + // Validate priority value + let validPriorities = ["standard", "expedited", "overnight"] + guard validPriorities.contains(input.priority) else { + throw ApplicationError( + message: "Invalid priority. Must be one of: \(validPriorities.joined(separator: ", "))", + type: "InvalidPriority", + isNonRetryable: true + ) + } + + // Cannot change priority after shipping has started + guard + currentState != "shipping" && currentState != "notifying" + && currentState != "completed" + else { + throw ApplicationError( + message: "Cannot change priority after shipping has started", + type: "InvalidState", + isNonRetryable: true + ) + } + + let oldPriority = priority + priority = input.priority + return "Priority changed from \(oldPriority) to \(priority)" + } + + // MARK: - Helper Methods + + private func waitIfPaused() async throws { + if isPaused { + // Wait until either resumed or cancelled + try await Workflow.condition { !self.isPaused || self.isCancelled } + } + } + + private func cancelledOutput() -> OrderOutput { + return OrderOutput( + orderId: orderId, + status: "cancelled", + processedId: nil, + trackingNumber: nil, + priority: priority + ) + } +} diff --git a/Package.swift b/Package.swift index 25a856c..53cdc7c 100644 --- a/Package.swift +++ b/Package.swift @@ -181,15 +181,5 @@ let package = Package( .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), ] ), - - // Examples - .executableTarget( - name: "GreetingExample", - dependencies: [ - "Temporal", - .product(name: "GRPCNIOTransportHTTP2", package: "grpc-swift-nio-transport"), - ], - path: "Examples/Greeting" - ), ] )