From 6aca68399cf502e04a838ac1e561db6c5cf25c37 Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Mon, 17 Aug 2026 11:57:45 -0400 Subject: [PATCH 1/3] Provision Cloud namespace for test run --- .github/scripts/cloud_namespace.rb | 192 ++++++++++++++ .github/workflows/ci.yml | 54 +++- .../test/cloud_namespace_script_test.rb | 240 ++++++++++++++++++ .../test/sig/cloud_namespace_script_test.rbs | 40 +++ 4 files changed, 512 insertions(+), 14 deletions(-) create mode 100644 .github/scripts/cloud_namespace.rb create mode 100644 temporalio/test/cloud_namespace_script_test.rb create mode 100644 temporalio/test/sig/cloud_namespace_script_test.rbs diff --git a/.github/scripts/cloud_namespace.rb b/.github/scripts/cloud_namespace.rb new file mode 100644 index 00000000..a8d3287b --- /dev/null +++ b/.github/scripts/cloud_namespace.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +require 'securerandom' +require 'temporalio/api' +require 'temporalio/client' +require 'timeout' + +# Keeps Cloud CI isolated by provisioning and deleting a namespace for each run. +module CloudNamespace + CLOUD_API_TARGET = 'saas-api.tmprl.cloud:443' + CLOUD_REGION = 'aws-ca-central-1' + OPERATION_TIMEOUT_SECONDS = 10 * 60 + RPC_TIMEOUT_SECONDS = 30 + FAILED_OPERATION_STATES = %i[STATE_FAILED STATE_CANCELLED STATE_REJECTED].freeze + + class << self + # Keep command dispatch separate so lifecycle behavior can be unit-tested without a subprocess. + def run(args, env: ENV) + service = cloud_service(env) + case args + in ['create'] + File.open(env.fetch('GITHUB_OUTPUT'), 'a') do |output| + create(service:, env:, output:) + end + in ['delete', namespace] + delete(service:, namespace:) + in ['delete', namespace, namespace_name] + delete(service:, namespace: (namespace unless namespace.empty?), namespace_name:) + else + raise ArgumentError, 'Usage: cloud_namespace.rb create|delete [namespace-name]' + end + end + + # Emit connection details incrementally so CI can clean up after a later provisioning failure. + def create( + service:, + env:, + output:, + monotonic: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, + sleeper: ->(duration) { Kernel.sleep(duration) } + ) + namespace_name = "sdk-ruby-ci-#{env.fetch('GITHUB_RUN_ID')}-#{env.fetch('GITHUB_RUN_ATTEMPT')}" + operation_id = SecureRandom.uuid + + # Record the deterministic name first so cleanup runs even if the create response is lost. + output.puts("namespace_name=#{namespace_name}") + output.flush + result = service.create_namespace( + Temporalio::Api::Cloud::CloudService::V1::CreateNamespaceRequest.new( + spec: Temporalio::Api::Cloud::Namespace::V1::NamespaceSpec.new( + name: namespace_name, + regions: [CLOUD_REGION], + retention_days: 1, + mtls_auth: Temporalio::Api::Cloud::Namespace::V1::MtlsAuthSpec.new( + accepted_client_ca: File.binread(env.fetch('TEMPORAL_CLOUD_CLIENT_CA_PATH')), + enabled: true + ) + ), + async_operation_id: operation_id + ), + rpc_options: rpc_options + ) + + output.puts("namespace=#{result.namespace}") + output.flush + wait_for_operation(service, result.async_operation, monotonic:, sleeper:) + + namespace = service.get_namespace( + Temporalio::Api::Cloud::CloudService::V1::GetNamespaceRequest.new(namespace: result.namespace), + rpc_options: rpc_options + ).namespace + address = namespace.endpoints&.mtls_grpc_address + raise "Cloud namespace #{result.namespace} did not provide an mTLS endpoint" if address.nil? || address.empty? + + output.puts("address=#{address}") + output.flush + end + + # Read the latest resource version because Cloud uses optimistic concurrency for deletion. + def delete( + service:, + namespace: nil, + namespace_name: nil, + monotonic: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, + sleeper: ->(duration) { Kernel.sleep(duration) } + ) + namespace ||= resolve_namespace(service, namespace_name, monotonic:, sleeper:) + return unless namespace + + existing_response = ignore_not_found do + service.get_namespace( + Temporalio::Api::Cloud::CloudService::V1::GetNamespaceRequest.new(namespace:), + rpc_options: rpc_options + ) + end + return unless existing_response + + result = ignore_not_found do + service.delete_namespace( + Temporalio::Api::Cloud::CloudService::V1::DeleteNamespaceRequest.new( + namespace:, + resource_version: existing_response.namespace.resource_version, + async_operation_id: SecureRandom.uuid + ), + rpc_options: rpc_options + ) + end + return unless result + + wait_for_operation(service, result.async_operation, monotonic:, sleeper:) + end + + # Honor the server's polling interval to avoid throttling the Cloud Operations API. + def wait_for_operation( + service, + operation, + monotonic: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, + sleeper: ->(duration) { Kernel.sleep(duration) } + ) + deadline = monotonic.call + OPERATION_TIMEOUT_SECONDS + loop do + remaining = deadline - monotonic.call + raise Timeout::Error, "Timed out waiting for Cloud operation #{operation.id}" if remaining <= 0 + + operation = service.get_async_operation( + Temporalio::Api::Cloud::CloudService::V1::GetAsyncOperationRequest.new( + async_operation_id: operation.id + ), + rpc_options: rpc_options(timeout: [RPC_TIMEOUT_SECONDS, remaining].min) + ).async_operation + return if operation.state == :STATE_FULFILLED + + if FAILED_OPERATION_STATES.include?(operation.state) + state = operation.state.to_s.delete_prefix('STATE_').downcase + raise "Cloud operation #{operation.id} #{state}: #{operation.failure_reason}" + end + + now = monotonic.call + raise Timeout::Error, "Timed out waiting for Cloud operation #{operation.id}" if now >= deadline + + duration = operation.check_duration + delay = duration ? duration.seconds + (duration.nanos / 1_000_000_000.0) : 1 + sleeper.call([delay, 1].max.clamp(0, deadline - now)) + end + end + + private + + def cloud_service(env) + Temporalio::Client::Connection.new( + target_host: CLOUD_API_TARGET, + api_key: env.fetch('TEMPORAL_CLIENT_CLOUD_API_KEY'), + rpc_metadata: { + 'temporal-cloud-api-version' => env.fetch('TEMPORAL_CLIENT_CLOUD_API_VERSION') + } + ).cloud_service + end + + def resolve_namespace(service, namespace_name, monotonic:, sleeper:) + raise ArgumentError, 'Namespace or namespace name required for deletion' unless namespace_name + + deadline = monotonic.call + RPC_TIMEOUT_SECONDS + loop do + result = service.get_namespaces( + Temporalio::Api::Cloud::CloudService::V1::GetNamespacesRequest.new(name: namespace_name), + rpc_options: rpc_options + ) + namespace = result.namespaces.find { |candidate| candidate.spec.name == namespace_name } + return namespace.namespace if namespace + + remaining = deadline - monotonic.call + return nil if remaining <= 0 + + sleeper.call([5, remaining].min) + end + end + + def ignore_not_found + yield + rescue Temporalio::Error::RPCError => e + raise unless e.code == Temporalio::Error::RPCError::Code::NOT_FOUND + + nil + end + + def rpc_options(timeout: RPC_TIMEOUT_SECONDS) + Temporalio::Client::RPCOptions.new(timeout:, override_retry: true) + end + end +end + +CloudNamespace.run(ARGV) if $PROGRAM_NAME == __FILE__ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec30ced7..133d5d1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -171,18 +171,11 @@ jobs: cloud-test: # Secrets are unavailable to workflows from forks. - if: ${{ github.event.pull_request.head.repo.full_name == '' || github.event.pull_request.head.repo.full_name == 'temporalio/sdk-ruby' }} + if: ${{ (github.event.pull_request.head.repo.full_name == '' || github.event.pull_request.head.repo.full_name == 'temporalio/sdk-ruby') && github.actor != 'dependabot[bot]' }} runs-on: ubuntu-latest - timeout-minutes: 30 - concurrency: - group: sdk-ruby-cloud-test - cancel-in-progress: false + timeout-minutes: 45 env: TEMPORAL_TEST_ENV_CONFIG_SERVER: "1" - TEMPORAL_ADDRESS: ca-central-1.aws.api.temporal.io:7233 - TEMPORAL_NAMESPACE: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }} - TEMPORAL_API_KEY: ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }} - TEMPORAL_GRPC_META_TEMPORAL_NAMESPACE: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }} steps: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -215,12 +208,45 @@ jobs: working-directory: ./temporalio run: bundle exec rake compile + - name: Generate Cloud test certificates + run: | + cert_dir="$RUNNER_TEMP/cloud-test-certs" + mkdir "$cert_dir" + openssl req -x509 -newkey rsa:2048 -nodes -days 1 \ + -keyout "$cert_dir/ca.key" -out "$cert_dir/ca.pem" \ + -subj '/CN=Temporal Ruby SDK Cloud CI CA' + openssl req -newkey rsa:2048 -nodes \ + -keyout "$cert_dir/client.key" -out "$cert_dir/client.csr" \ + -subj '/CN=Temporal Ruby SDK Cloud CI' + openssl x509 -req -days 1 -in "$cert_dir/client.csr" \ + -CA "$cert_dir/ca.pem" -CAkey "$cert_dir/ca.key" -CAcreateserial \ + -out "$cert_dir/client.pem" -extfile <(printf 'extendedKeyUsage=clientAuth') + { + echo "TEMPORAL_CLOUD_CLIENT_CA_PATH=$cert_dir/ca.pem" + echo "TEMPORAL_TLS_CLIENT_CERT_PATH=$cert_dir/client.pem" + echo "TEMPORAL_TLS_CLIENT_KEY_PATH=$cert_dir/client.key" + } >> "$GITHUB_ENV" + + - name: Create Cloud namespace + id: create-cloud-namespace + working-directory: ./temporalio + run: bundle exec ruby ../.github/scripts/cloud_namespace.rb create + env: + TEMPORAL_CLIENT_CLOUD_API_KEY: ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }} + TEMPORAL_CLIENT_CLOUD_API_VERSION: v0.19.1 + - name: Test Ruby against Temporal Cloud - if: ${{ env.TEMPORAL_NAMESPACE != '' && env.TEMPORAL_API_KEY != '' }} working-directory: ./temporalio - timeout-minutes: 20 + timeout-minutes: 15 + env: + TEMPORAL_ADDRESS: ${{ steps.create-cloud-namespace.outputs.address }} + TEMPORAL_NAMESPACE: ${{ steps.create-cloud-namespace.outputs.namespace }} run: bundle exec rake test TEST=test/worker_workflow_test.rb TESTOPTS="--name=/WorkerWorkflowTest#test_simple/" - - name: Report unavailable Cloud configuration - if: ${{ env.TEMPORAL_NAMESPACE == '' || env.TEMPORAL_API_KEY == '' }} - run: echo "Temporal Cloud test skipped because its namespace or API key is unavailable" + - name: Delete Cloud namespace + if: ${{ always() && steps.create-cloud-namespace.outputs.namespace_name != '' }} + working-directory: ./temporalio + run: bundle exec ruby ../.github/scripts/cloud_namespace.rb delete "${{ steps.create-cloud-namespace.outputs.namespace }}" "${{ steps.create-cloud-namespace.outputs.namespace_name }}" + env: + TEMPORAL_CLIENT_CLOUD_API_KEY: ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }} + TEMPORAL_CLIENT_CLOUD_API_VERSION: v0.19.1 diff --git a/temporalio/test/cloud_namespace_script_test.rb b/temporalio/test/cloud_namespace_script_test.rb new file mode 100644 index 00000000..d2367d15 --- /dev/null +++ b/temporalio/test/cloud_namespace_script_test.rb @@ -0,0 +1,240 @@ +# frozen_string_literal: true + +require 'stringio' +require 'tempfile' +require 'test' + +require_relative '../../.github/scripts/cloud_namespace' + +class CloudNamespaceScriptTest < Test + class FakeCloudService + attr_accessor :create_error, :create_response, :delete_response, :namespace_error, :namespace_response, + :namespaces_response, :operation_error + attr_reader :create_request, :delete_request, :operation_requests, :rpc_options + + def initialize(operation_responses: []) + @operation_responses = operation_responses + @operation_requests = [] + end + + def create_namespace(request, rpc_options:) + @create_request = request + @rpc_options = rpc_options + raise create_error if create_error + + create_response + end + + def delete_namespace(request, rpc_options:) + @delete_request = request + @rpc_options = rpc_options + delete_response + end + + def get_namespace(_request, rpc_options:) + @rpc_options = rpc_options + raise namespace_error if namespace_error + + namespace_response + end + + def get_namespaces(_request, rpc_options:) + @rpc_options = rpc_options + namespaces_response + end + + def get_async_operation(request, rpc_options:) + @operation_requests << request + @rpc_options = rpc_options + raise operation_error if operation_error + + operation = @operation_responses.shift || raise('No operation response configured') + Temporalio::Api::Cloud::CloudService::V1::GetAsyncOperationResponse.new(async_operation: operation) + end + end + + def test_create_namespace + service = FakeCloudService.new(operation_responses: [operation(:STATE_FULFILLED)]) + service.create_response = Temporalio::Api::Cloud::CloudService::V1::CreateNamespaceResponse.new( + namespace: 'sdk-ruby-ci-123-2.account-id', + async_operation: operation(:STATE_PENDING) + ) + service.namespace_response = namespace_response( + namespace: 'sdk-ruby-ci-123-2.account-id', + address: 'sdk-ruby-ci-123-2.account-id.tmprl.cloud:7233' + ) + + with_ca_env do |env| + output = StringIO.new + CloudNamespace.create(service:, env:, output:, monotonic: -> { 0 }, sleeper: ->(_duration) {}) + + request = service.create_request + assert_equal 'sdk-ruby-ci-123-2', request.spec.name + assert_equal ['aws-ca-central-1'], request.spec.regions + assert_equal 1, request.spec.retention_days + assert request.spec.mtls_auth.enabled + assert_equal 'test-ca', request.spec.mtls_auth.accepted_client_ca + refute_empty request.async_operation_id + assert_equal 30, service.rpc_options.timeout + assert service.rpc_options.override_retry + assert_equal <<~OUTPUT, output.string + namespace_name=sdk-ruby-ci-123-2 + namespace=sdk-ruby-ci-123-2.account-id + address=sdk-ruby-ci-123-2.account-id.tmprl.cloud:7233 + OUTPUT + end + end + + def test_create_records_namespace_before_create_failure + service = FakeCloudService.new + service.create_error = RuntimeError.new('create response lost') + + with_ca_env do |env| + output = StringIO.new + error = assert_raises(RuntimeError) do + CloudNamespace.create(service:, env:, output:, monotonic: -> { 0 }, sleeper: ->(_duration) {}) + end + assert_equal "namespace_name=sdk-ruby-ci-123-2\n", output.string + assert_includes error.message, 'create response lost' + end + end + + def test_wait_for_operation_honors_check_duration_and_timeout + service = FakeCloudService.new( + operation_responses: [operation(:STATE_PENDING, check_seconds: 2.5), operation(:STATE_FULFILLED)] + ) + delays = [] + CloudNamespace.wait_for_operation( + service, + operation(:STATE_PENDING), + monotonic: -> { 0 }, + sleeper: ->(duration) { delays << duration } + ) + assert_equal [2.5], delays + + times = [0, CloudNamespace::OPERATION_TIMEOUT_SECONDS + 1] + service = FakeCloudService.new(operation_responses: [operation(:STATE_PENDING)]) + assert_raises(Timeout::Error) do + CloudNamespace.wait_for_operation( + service, + operation(:STATE_PENDING), + monotonic: -> { times.shift || times.last }, + sleeper: ->(_duration) {} + ) + end + end + + def test_delete_namespace_uses_resource_version + service = FakeCloudService.new(operation_responses: [operation(:STATE_FULFILLED)]) + service.namespace_response = namespace_response(namespace: 'sdk-ruby-ci-123-2', resource_version: 'version-1') + service.delete_response = Temporalio::Api::Cloud::CloudService::V1::DeleteNamespaceResponse.new( + async_operation: operation(:STATE_PENDING) + ) + + CloudNamespace.delete( + service:, + namespace: 'sdk-ruby-ci-123-2', + monotonic: -> { 0 }, + sleeper: ->(_duration) {} + ) + + assert_equal 'sdk-ruby-ci-123-2', service.delete_request.namespace + assert_equal 'version-1', service.delete_request.resource_version + refute_empty service.delete_request.async_operation_id + assert_equal 30, service.rpc_options.timeout + assert service.rpc_options.override_retry + end + + def test_delete_resolves_namespace_name_after_ambiguous_create + service = FakeCloudService.new(operation_responses: [operation(:STATE_FULFILLED)]) + service.namespaces_response = Temporalio::Api::Cloud::CloudService::V1::GetNamespacesResponse.new( + namespaces: [ + Temporalio::Api::Cloud::Namespace::V1::Namespace.new( + namespace: 'sdk-ruby-ci-123-2.account-id', + spec: Temporalio::Api::Cloud::Namespace::V1::NamespaceSpec.new(name: 'sdk-ruby-ci-123-2') + ) + ] + ) + service.namespace_response = namespace_response( + namespace: 'sdk-ruby-ci-123-2.account-id', + resource_version: 'version-1' + ) + service.delete_response = Temporalio::Api::Cloud::CloudService::V1::DeleteNamespaceResponse.new( + async_operation: operation(:STATE_PENDING) + ) + + CloudNamespace.delete( + service:, + namespace_name: 'sdk-ruby-ci-123-2', + monotonic: -> { 0 }, + sleeper: ->(_duration) {} + ) + + assert_equal 'sdk-ruby-ci-123-2.account-id', service.delete_request.namespace + end + + def test_delete_only_ignores_not_found_while_locating_namespace + service = FakeCloudService.new + service.namespace_error = rpc_not_found + CloudNamespace.delete(service:, namespace: 'missing.account-id') + assert_nil service.delete_request + + service = FakeCloudService.new + service.namespace_response = namespace_response(namespace: 'existing.account-id', resource_version: 'version-1') + service.delete_response = Temporalio::Api::Cloud::CloudService::V1::DeleteNamespaceResponse.new( + async_operation: operation(:STATE_PENDING) + ) + service.operation_error = rpc_not_found + assert_raises(Temporalio::Error::RPCError) do + CloudNamespace.delete(service:, namespace: 'existing.account-id') + end + end + + private + + def operation(state, failure_reason: '', check_seconds: nil) + duration = if check_seconds + seconds = check_seconds.floor + Google::Protobuf::Duration.new( + seconds:, + nanos: ((check_seconds - seconds) * 1_000_000_000).to_i + ) + end + Temporalio::Api::Cloud::Operation::V1::AsyncOperation.new( + id: 'operation-id', + state:, + failure_reason:, + check_duration: duration + ) + end + + def namespace_response(namespace:, resource_version: '', address: '') + Temporalio::Api::Cloud::CloudService::V1::GetNamespaceResponse.new( + namespace: Temporalio::Api::Cloud::Namespace::V1::Namespace.new( + namespace:, + resource_version:, + endpoints: Temporalio::Api::Cloud::Namespace::V1::Endpoints.new(mtls_grpc_address: address) + ) + ) + end + + def rpc_not_found + Temporalio::Error::RPCError.new( + 'not found', + code: Temporalio::Error::RPCError::Code::NOT_FOUND, + raw_grpc_status: nil + ) + end + + def with_ca_env + Tempfile.create do |file| + file.write('test-ca') + file.flush + yield( + 'GITHUB_RUN_ID' => '123', + 'GITHUB_RUN_ATTEMPT' => '2', + 'TEMPORAL_CLOUD_CLIENT_CA_PATH' => file.path + ) + end + end +end diff --git a/temporalio/test/sig/cloud_namespace_script_test.rbs b/temporalio/test/sig/cloud_namespace_script_test.rbs new file mode 100644 index 00000000..8c7a3b2b --- /dev/null +++ b/temporalio/test/sig/cloud_namespace_script_test.rbs @@ -0,0 +1,40 @@ +class CloudNamespaceScriptTest < Test + class FakeCloudService + attr_accessor create_error: Exception? + attr_accessor create_response: untyped + attr_accessor delete_response: untyped + attr_accessor namespace_error: Exception? + attr_accessor namespace_response: untyped + attr_accessor namespaces_response: untyped + attr_accessor operation_error: Exception? + attr_reader create_request: untyped + attr_reader delete_request: untyped + attr_reader operation_requests: Array[untyped] + attr_reader rpc_options: Temporalio::Client::RPCOptions + + def initialize: (?operation_responses: Array[untyped]) -> void + def create_namespace: (untyped request, rpc_options: Temporalio::Client::RPCOptions) -> untyped + def delete_namespace: (untyped request, rpc_options: Temporalio::Client::RPCOptions) -> untyped + def get_namespace: (untyped request, rpc_options: Temporalio::Client::RPCOptions) -> untyped + def get_namespaces: (untyped request, rpc_options: Temporalio::Client::RPCOptions) -> untyped + def get_async_operation: (untyped request, rpc_options: Temporalio::Client::RPCOptions) -> untyped + end + + private + + def operation: ( + Symbol state, + ?failure_reason: String, + ?check_seconds: Float? + ) -> untyped + + def namespace_response: ( + namespace: String, + ?resource_version: String, + ?address: String + ) -> untyped + + def rpc_not_found: -> Temporalio::Error::RPCError + + def with_ca_env: [T] () { (Hash[String, String]) -> T } -> T +end From 05a1ac5c66f2ae6c9baff157baadd4d7cc21bcfb Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Mon, 17 Aug 2026 14:59:42 -0400 Subject: [PATCH 2/3] Simplify Cloud namespace lifecycle --- .github/scripts/cloud_namespace.rb | 140 ++++------ .github/workflows/ci.yml | 15 +- .../test/cloud_namespace_script_test.rb | 240 ------------------ .../test/sig/cloud_namespace_script_test.rbs | 40 --- 4 files changed, 55 insertions(+), 380 deletions(-) delete mode 100644 temporalio/test/cloud_namespace_script_test.rb delete mode 100644 temporalio/test/sig/cloud_namespace_script_test.rbs diff --git a/.github/scripts/cloud_namespace.rb b/.github/scripts/cloud_namespace.rb index a8d3287b..a61851c1 100644 --- a/.github/scripts/cloud_namespace.rb +++ b/.github/scripts/cloud_namespace.rb @@ -10,28 +10,25 @@ module CloudNamespace CLOUD_API_TARGET = 'saas-api.tmprl.cloud:443' CLOUD_REGION = 'aws-ca-central-1' OPERATION_TIMEOUT_SECONDS = 10 * 60 - RPC_TIMEOUT_SECONDS = 30 FAILED_OPERATION_STATES = %i[STATE_FAILED STATE_CANCELLED STATE_REJECTED].freeze class << self - # Keep command dispatch separate so lifecycle behavior can be unit-tested without a subprocess. + # Keep command dispatch separate so the lifecycle can run inside the repository's Ruby bundle. def run(args, env: ENV) service = cloud_service(env) case args in ['create'] - File.open(env.fetch('GITHUB_OUTPUT'), 'a') do |output| + File.open(required_env(env, 'GITHUB_OUTPUT'), 'a') do |output| create(service:, env:, output:) end in ['delete', namespace] delete(service:, namespace:) - in ['delete', namespace, namespace_name] - delete(service:, namespace: (namespace unless namespace.empty?), namespace_name:) else - raise ArgumentError, 'Usage: cloud_namespace.rb create|delete [namespace-name]' + raise ArgumentError, 'Usage: cloud_namespace.rb create|delete ' end end - # Emit connection details incrementally so CI can clean up after a later provisioning failure. + # Emit the namespace before polling so cleanup can run if provisioning later fails. def create( service:, env:, @@ -39,108 +36,85 @@ def create( monotonic: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, sleeper: ->(duration) { Kernel.sleep(duration) } ) - namespace_name = "sdk-ruby-ci-#{env.fetch('GITHUB_RUN_ID')}-#{env.fetch('GITHUB_RUN_ATTEMPT')}" - operation_id = SecureRandom.uuid - - # Record the deterministic name first so cleanup runs even if the create response is lost. - output.puts("namespace_name=#{namespace_name}") - output.flush + namespace_name = "sdk-ruby-ci-#{required_env(env, 'GITHUB_RUN_ID')}-#{required_env(env, 'GITHUB_RUN_ATTEMPT')}" result = service.create_namespace( Temporalio::Api::Cloud::CloudService::V1::CreateNamespaceRequest.new( spec: Temporalio::Api::Cloud::Namespace::V1::NamespaceSpec.new( name: namespace_name, - regions: [CLOUD_REGION], + replicas: [Temporalio::Api::Cloud::Namespace::V1::ReplicaSpec.new(region: CLOUD_REGION)], retention_days: 1, mtls_auth: Temporalio::Api::Cloud::Namespace::V1::MtlsAuthSpec.new( - accepted_client_ca: File.binread(env.fetch('TEMPORAL_CLOUD_CLIENT_CA_PATH')), + accepted_client_ca: File.binread(required_env(env, 'TEMPORAL_CLOUD_CLIENT_CA_PATH')), enabled: true ) ), - async_operation_id: operation_id - ), - rpc_options: rpc_options + async_operation_id: SecureRandom.uuid + ) ) + namespace = result.namespace + raise 'Create namespace response did not include a namespace' if namespace.nil? || namespace.empty? - output.puts("namespace=#{result.namespace}") + output.puts("namespace=#{namespace}") output.flush wait_for_operation(service, result.async_operation, monotonic:, sleeper:) - - namespace = service.get_namespace( - Temporalio::Api::Cloud::CloudService::V1::GetNamespaceRequest.new(namespace: result.namespace), - rpc_options: rpc_options - ).namespace - address = namespace.endpoints&.mtls_grpc_address - raise "Cloud namespace #{result.namespace} did not provide an mTLS endpoint" if address.nil? || address.empty? - - output.puts("address=#{address}") - output.flush end - # Read the latest resource version because Cloud uses optimistic concurrency for deletion. + # Read the current resource version because Cloud uses optimistic concurrency for deletion. def delete( service:, - namespace: nil, - namespace_name: nil, + namespace:, monotonic: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, sleeper: ->(duration) { Kernel.sleep(duration) } ) - namespace ||= resolve_namespace(service, namespace_name, monotonic:, sleeper:) - return unless namespace - - existing_response = ignore_not_found do - service.get_namespace( - Temporalio::Api::Cloud::CloudService::V1::GetNamespaceRequest.new(namespace:), - rpc_options: rpc_options - ) - end - return unless existing_response - - result = ignore_not_found do - service.delete_namespace( - Temporalio::Api::Cloud::CloudService::V1::DeleteNamespaceRequest.new( - namespace:, - resource_version: existing_response.namespace.resource_version, - async_operation_id: SecureRandom.uuid - ), - rpc_options: rpc_options - ) + existing = service.get_namespace( + Temporalio::Api::Cloud::CloudService::V1::GetNamespaceRequest.new(namespace:) + ).namespace + resource_version = existing&.resource_version + if resource_version.nil? || resource_version.empty? + raise "Cloud namespace #{namespace} did not include a resource version" end - return unless result + result = service.delete_namespace( + Temporalio::Api::Cloud::CloudService::V1::DeleteNamespaceRequest.new( + namespace:, + resource_version:, + async_operation_id: SecureRandom.uuid + ) + ) wait_for_operation(service, result.async_operation, monotonic:, sleeper:) end - # Honor the server's polling interval to avoid throttling the Cloud Operations API. + # Honor server polling guidance while bounding the overall asynchronous operation. def wait_for_operation( service, operation, monotonic: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, sleeper: ->(duration) { Kernel.sleep(duration) } ) + operation_id = operation&.id + raise 'Cloud operation response did not include an ID' if operation_id.nil? || operation_id.empty? + deadline = monotonic.call + OPERATION_TIMEOUT_SECONDS loop do - remaining = deadline - monotonic.call - raise Timeout::Error, "Timed out waiting for Cloud operation #{operation.id}" if remaining <= 0 - operation = service.get_async_operation( Temporalio::Api::Cloud::CloudService::V1::GetAsyncOperationRequest.new( - async_operation_id: operation.id - ), - rpc_options: rpc_options(timeout: [RPC_TIMEOUT_SECONDS, remaining].min) + async_operation_id: operation_id + ) ).async_operation + raise "Cloud operation #{operation_id} could not be read" unless operation return if operation.state == :STATE_FULFILLED if FAILED_OPERATION_STATES.include?(operation.state) state = operation.state.to_s.delete_prefix('STATE_').downcase - raise "Cloud operation #{operation.id} #{state}: #{operation.failure_reason}" + raise "Cloud operation #{operation_id} #{state}: #{operation.failure_reason}" end - now = monotonic.call - raise Timeout::Error, "Timed out waiting for Cloud operation #{operation.id}" if now >= deadline + remaining = deadline - monotonic.call + raise Timeout::Error, "Timed out waiting for Cloud operation #{operation_id}" if remaining <= 0 duration = operation.check_duration - delay = duration ? duration.seconds + (duration.nanos / 1_000_000_000.0) : 1 - sleeper.call([delay, 1].max.clamp(0, deadline - now)) + delay = duration ? duration.seconds + (duration.nanos / 1_000_000_000.0) : 10 + sleeper.call([delay, 1].max.clamp(0, remaining)) end end @@ -149,42 +123,18 @@ def wait_for_operation( def cloud_service(env) Temporalio::Client::Connection.new( target_host: CLOUD_API_TARGET, - api_key: env.fetch('TEMPORAL_CLIENT_CLOUD_API_KEY'), + api_key: required_env(env, 'TEMPORAL_CLIENT_CLOUD_API_KEY'), rpc_metadata: { - 'temporal-cloud-api-version' => env.fetch('TEMPORAL_CLIENT_CLOUD_API_VERSION') + 'temporal-cloud-api-version' => required_env(env, 'TEMPORAL_CLIENT_CLOUD_API_VERSION') } ).cloud_service end - def resolve_namespace(service, namespace_name, monotonic:, sleeper:) - raise ArgumentError, 'Namespace or namespace name required for deletion' unless namespace_name - - deadline = monotonic.call + RPC_TIMEOUT_SECONDS - loop do - result = service.get_namespaces( - Temporalio::Api::Cloud::CloudService::V1::GetNamespacesRequest.new(name: namespace_name), - rpc_options: rpc_options - ) - namespace = result.namespaces.find { |candidate| candidate.spec.name == namespace_name } - return namespace.namespace if namespace - - remaining = deadline - monotonic.call - return nil if remaining <= 0 - - sleeper.call([5, remaining].min) - end - end - - def ignore_not_found - yield - rescue Temporalio::Error::RPCError => e - raise unless e.code == Temporalio::Error::RPCError::Code::NOT_FOUND - - nil - end + def required_env(env, name) + value = env.fetch(name, '') + return value unless value.empty? - def rpc_options(timeout: RPC_TIMEOUT_SECONDS) - Temporalio::Client::RPCOptions.new(timeout:, override_retry: true) + raise "Missing required environment variable #{name}" end end end diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 133d5d1e..59d33b85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -176,6 +176,7 @@ jobs: timeout-minutes: 45 env: TEMPORAL_TEST_ENV_CONFIG_SERVER: "1" + TEMPORAL_CLIENT_CLOUD_API_VERSION: v0.19.1 steps: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -233,20 +234,24 @@ jobs: run: bundle exec ruby ../.github/scripts/cloud_namespace.rb create env: TEMPORAL_CLIENT_CLOUD_API_KEY: ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }} - TEMPORAL_CLIENT_CLOUD_API_VERSION: v0.19.1 - name: Test Ruby against Temporal Cloud working-directory: ./temporalio timeout-minutes: 15 env: - TEMPORAL_ADDRESS: ${{ steps.create-cloud-namespace.outputs.address }} + TEMPORAL_ADDRESS: ${{ steps.create-cloud-namespace.outputs.namespace }}.tmprl.cloud:7233 TEMPORAL_NAMESPACE: ${{ steps.create-cloud-namespace.outputs.namespace }} run: bundle exec rake test TEST=test/worker_workflow_test.rb TESTOPTS="--name=/WorkerWorkflowTest#test_simple/" - name: Delete Cloud namespace - if: ${{ always() && steps.create-cloud-namespace.outputs.namespace_name != '' }} + id: delete-cloud-namespace + if: ${{ always() && steps.create-cloud-namespace.outputs.namespace != '' }} + continue-on-error: true working-directory: ./temporalio - run: bundle exec ruby ../.github/scripts/cloud_namespace.rb delete "${{ steps.create-cloud-namespace.outputs.namespace }}" "${{ steps.create-cloud-namespace.outputs.namespace_name }}" + run: bundle exec ruby ../.github/scripts/cloud_namespace.rb delete "${{ steps.create-cloud-namespace.outputs.namespace }}" env: TEMPORAL_CLIENT_CLOUD_API_KEY: ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }} - TEMPORAL_CLIENT_CLOUD_API_VERSION: v0.19.1 + + - name: Report Cloud namespace cleanup failure + if: ${{ always() && steps.delete-cloud-namespace.outcome == 'failure' }} + run: echo "::warning title=Cloud namespace cleanup failed::Failed to delete Cloud namespace ${{ steps.create-cloud-namespace.outputs.namespace }}" diff --git a/temporalio/test/cloud_namespace_script_test.rb b/temporalio/test/cloud_namespace_script_test.rb deleted file mode 100644 index d2367d15..00000000 --- a/temporalio/test/cloud_namespace_script_test.rb +++ /dev/null @@ -1,240 +0,0 @@ -# frozen_string_literal: true - -require 'stringio' -require 'tempfile' -require 'test' - -require_relative '../../.github/scripts/cloud_namespace' - -class CloudNamespaceScriptTest < Test - class FakeCloudService - attr_accessor :create_error, :create_response, :delete_response, :namespace_error, :namespace_response, - :namespaces_response, :operation_error - attr_reader :create_request, :delete_request, :operation_requests, :rpc_options - - def initialize(operation_responses: []) - @operation_responses = operation_responses - @operation_requests = [] - end - - def create_namespace(request, rpc_options:) - @create_request = request - @rpc_options = rpc_options - raise create_error if create_error - - create_response - end - - def delete_namespace(request, rpc_options:) - @delete_request = request - @rpc_options = rpc_options - delete_response - end - - def get_namespace(_request, rpc_options:) - @rpc_options = rpc_options - raise namespace_error if namespace_error - - namespace_response - end - - def get_namespaces(_request, rpc_options:) - @rpc_options = rpc_options - namespaces_response - end - - def get_async_operation(request, rpc_options:) - @operation_requests << request - @rpc_options = rpc_options - raise operation_error if operation_error - - operation = @operation_responses.shift || raise('No operation response configured') - Temporalio::Api::Cloud::CloudService::V1::GetAsyncOperationResponse.new(async_operation: operation) - end - end - - def test_create_namespace - service = FakeCloudService.new(operation_responses: [operation(:STATE_FULFILLED)]) - service.create_response = Temporalio::Api::Cloud::CloudService::V1::CreateNamespaceResponse.new( - namespace: 'sdk-ruby-ci-123-2.account-id', - async_operation: operation(:STATE_PENDING) - ) - service.namespace_response = namespace_response( - namespace: 'sdk-ruby-ci-123-2.account-id', - address: 'sdk-ruby-ci-123-2.account-id.tmprl.cloud:7233' - ) - - with_ca_env do |env| - output = StringIO.new - CloudNamespace.create(service:, env:, output:, monotonic: -> { 0 }, sleeper: ->(_duration) {}) - - request = service.create_request - assert_equal 'sdk-ruby-ci-123-2', request.spec.name - assert_equal ['aws-ca-central-1'], request.spec.regions - assert_equal 1, request.spec.retention_days - assert request.spec.mtls_auth.enabled - assert_equal 'test-ca', request.spec.mtls_auth.accepted_client_ca - refute_empty request.async_operation_id - assert_equal 30, service.rpc_options.timeout - assert service.rpc_options.override_retry - assert_equal <<~OUTPUT, output.string - namespace_name=sdk-ruby-ci-123-2 - namespace=sdk-ruby-ci-123-2.account-id - address=sdk-ruby-ci-123-2.account-id.tmprl.cloud:7233 - OUTPUT - end - end - - def test_create_records_namespace_before_create_failure - service = FakeCloudService.new - service.create_error = RuntimeError.new('create response lost') - - with_ca_env do |env| - output = StringIO.new - error = assert_raises(RuntimeError) do - CloudNamespace.create(service:, env:, output:, monotonic: -> { 0 }, sleeper: ->(_duration) {}) - end - assert_equal "namespace_name=sdk-ruby-ci-123-2\n", output.string - assert_includes error.message, 'create response lost' - end - end - - def test_wait_for_operation_honors_check_duration_and_timeout - service = FakeCloudService.new( - operation_responses: [operation(:STATE_PENDING, check_seconds: 2.5), operation(:STATE_FULFILLED)] - ) - delays = [] - CloudNamespace.wait_for_operation( - service, - operation(:STATE_PENDING), - monotonic: -> { 0 }, - sleeper: ->(duration) { delays << duration } - ) - assert_equal [2.5], delays - - times = [0, CloudNamespace::OPERATION_TIMEOUT_SECONDS + 1] - service = FakeCloudService.new(operation_responses: [operation(:STATE_PENDING)]) - assert_raises(Timeout::Error) do - CloudNamespace.wait_for_operation( - service, - operation(:STATE_PENDING), - monotonic: -> { times.shift || times.last }, - sleeper: ->(_duration) {} - ) - end - end - - def test_delete_namespace_uses_resource_version - service = FakeCloudService.new(operation_responses: [operation(:STATE_FULFILLED)]) - service.namespace_response = namespace_response(namespace: 'sdk-ruby-ci-123-2', resource_version: 'version-1') - service.delete_response = Temporalio::Api::Cloud::CloudService::V1::DeleteNamespaceResponse.new( - async_operation: operation(:STATE_PENDING) - ) - - CloudNamespace.delete( - service:, - namespace: 'sdk-ruby-ci-123-2', - monotonic: -> { 0 }, - sleeper: ->(_duration) {} - ) - - assert_equal 'sdk-ruby-ci-123-2', service.delete_request.namespace - assert_equal 'version-1', service.delete_request.resource_version - refute_empty service.delete_request.async_operation_id - assert_equal 30, service.rpc_options.timeout - assert service.rpc_options.override_retry - end - - def test_delete_resolves_namespace_name_after_ambiguous_create - service = FakeCloudService.new(operation_responses: [operation(:STATE_FULFILLED)]) - service.namespaces_response = Temporalio::Api::Cloud::CloudService::V1::GetNamespacesResponse.new( - namespaces: [ - Temporalio::Api::Cloud::Namespace::V1::Namespace.new( - namespace: 'sdk-ruby-ci-123-2.account-id', - spec: Temporalio::Api::Cloud::Namespace::V1::NamespaceSpec.new(name: 'sdk-ruby-ci-123-2') - ) - ] - ) - service.namespace_response = namespace_response( - namespace: 'sdk-ruby-ci-123-2.account-id', - resource_version: 'version-1' - ) - service.delete_response = Temporalio::Api::Cloud::CloudService::V1::DeleteNamespaceResponse.new( - async_operation: operation(:STATE_PENDING) - ) - - CloudNamespace.delete( - service:, - namespace_name: 'sdk-ruby-ci-123-2', - monotonic: -> { 0 }, - sleeper: ->(_duration) {} - ) - - assert_equal 'sdk-ruby-ci-123-2.account-id', service.delete_request.namespace - end - - def test_delete_only_ignores_not_found_while_locating_namespace - service = FakeCloudService.new - service.namespace_error = rpc_not_found - CloudNamespace.delete(service:, namespace: 'missing.account-id') - assert_nil service.delete_request - - service = FakeCloudService.new - service.namespace_response = namespace_response(namespace: 'existing.account-id', resource_version: 'version-1') - service.delete_response = Temporalio::Api::Cloud::CloudService::V1::DeleteNamespaceResponse.new( - async_operation: operation(:STATE_PENDING) - ) - service.operation_error = rpc_not_found - assert_raises(Temporalio::Error::RPCError) do - CloudNamespace.delete(service:, namespace: 'existing.account-id') - end - end - - private - - def operation(state, failure_reason: '', check_seconds: nil) - duration = if check_seconds - seconds = check_seconds.floor - Google::Protobuf::Duration.new( - seconds:, - nanos: ((check_seconds - seconds) * 1_000_000_000).to_i - ) - end - Temporalio::Api::Cloud::Operation::V1::AsyncOperation.new( - id: 'operation-id', - state:, - failure_reason:, - check_duration: duration - ) - end - - def namespace_response(namespace:, resource_version: '', address: '') - Temporalio::Api::Cloud::CloudService::V1::GetNamespaceResponse.new( - namespace: Temporalio::Api::Cloud::Namespace::V1::Namespace.new( - namespace:, - resource_version:, - endpoints: Temporalio::Api::Cloud::Namespace::V1::Endpoints.new(mtls_grpc_address: address) - ) - ) - end - - def rpc_not_found - Temporalio::Error::RPCError.new( - 'not found', - code: Temporalio::Error::RPCError::Code::NOT_FOUND, - raw_grpc_status: nil - ) - end - - def with_ca_env - Tempfile.create do |file| - file.write('test-ca') - file.flush - yield( - 'GITHUB_RUN_ID' => '123', - 'GITHUB_RUN_ATTEMPT' => '2', - 'TEMPORAL_CLOUD_CLIENT_CA_PATH' => file.path - ) - end - end -end diff --git a/temporalio/test/sig/cloud_namespace_script_test.rbs b/temporalio/test/sig/cloud_namespace_script_test.rbs deleted file mode 100644 index 8c7a3b2b..00000000 --- a/temporalio/test/sig/cloud_namespace_script_test.rbs +++ /dev/null @@ -1,40 +0,0 @@ -class CloudNamespaceScriptTest < Test - class FakeCloudService - attr_accessor create_error: Exception? - attr_accessor create_response: untyped - attr_accessor delete_response: untyped - attr_accessor namespace_error: Exception? - attr_accessor namespace_response: untyped - attr_accessor namespaces_response: untyped - attr_accessor operation_error: Exception? - attr_reader create_request: untyped - attr_reader delete_request: untyped - attr_reader operation_requests: Array[untyped] - attr_reader rpc_options: Temporalio::Client::RPCOptions - - def initialize: (?operation_responses: Array[untyped]) -> void - def create_namespace: (untyped request, rpc_options: Temporalio::Client::RPCOptions) -> untyped - def delete_namespace: (untyped request, rpc_options: Temporalio::Client::RPCOptions) -> untyped - def get_namespace: (untyped request, rpc_options: Temporalio::Client::RPCOptions) -> untyped - def get_namespaces: (untyped request, rpc_options: Temporalio::Client::RPCOptions) -> untyped - def get_async_operation: (untyped request, rpc_options: Temporalio::Client::RPCOptions) -> untyped - end - - private - - def operation: ( - Symbol state, - ?failure_reason: String, - ?check_seconds: Float? - ) -> untyped - - def namespace_response: ( - namespace: String, - ?resource_version: String, - ?address: String - ) -> untyped - - def rpc_not_found: -> Temporalio::Error::RPCError - - def with_ca_env: [T] () { (Hash[String, String]) -> T } -> T -end From 6e0e06577c9ddb76c669281cc1f4c254a9013504 Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Tue, 18 Aug 2026 15:10:40 -0400 Subject: [PATCH 3/3] Address Cloud namespace review feedback --- .github/workflows/ci.yml | 4 +- .../extra}/cloud_namespace.rb | 61 +++++++------------ 2 files changed, 24 insertions(+), 41 deletions(-) rename {.github/scripts => temporalio/extra}/cloud_namespace.rb (68%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59d33b85..767ebe23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -231,7 +231,7 @@ jobs: - name: Create Cloud namespace id: create-cloud-namespace working-directory: ./temporalio - run: bundle exec ruby ../.github/scripts/cloud_namespace.rb create + run: bundle exec ruby extra/cloud_namespace.rb create env: TEMPORAL_CLIENT_CLOUD_API_KEY: ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }} @@ -248,7 +248,7 @@ jobs: if: ${{ always() && steps.create-cloud-namespace.outputs.namespace != '' }} continue-on-error: true working-directory: ./temporalio - run: bundle exec ruby ../.github/scripts/cloud_namespace.rb delete "${{ steps.create-cloud-namespace.outputs.namespace }}" + run: bundle exec ruby extra/cloud_namespace.rb delete "${{ steps.create-cloud-namespace.outputs.namespace }}" env: TEMPORAL_CLIENT_CLOUD_API_KEY: ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }} diff --git a/.github/scripts/cloud_namespace.rb b/temporalio/extra/cloud_namespace.rb similarity index 68% rename from .github/scripts/cloud_namespace.rb rename to temporalio/extra/cloud_namespace.rb index a61851c1..fd320561 100644 --- a/.github/scripts/cloud_namespace.rb +++ b/temporalio/extra/cloud_namespace.rb @@ -14,29 +14,21 @@ module CloudNamespace class << self # Keep command dispatch separate so the lifecycle can run inside the repository's Ruby bundle. - def run(args, env: ENV) - service = cloud_service(env) + def run(args) case args in ['create'] - File.open(required_env(env, 'GITHUB_OUTPUT'), 'a') do |output| - create(service:, env:, output:) - end + create in ['delete', namespace] - delete(service:, namespace:) + delete(namespace) else raise ArgumentError, 'Usage: cloud_namespace.rb create|delete ' end end # Emit the namespace before polling so cleanup can run if provisioning later fails. - def create( - service:, - env:, - output:, - monotonic: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, - sleeper: ->(duration) { Kernel.sleep(duration) } - ) - namespace_name = "sdk-ruby-ci-#{required_env(env, 'GITHUB_RUN_ID')}-#{required_env(env, 'GITHUB_RUN_ATTEMPT')}" + def create + service = cloud_service + namespace_name = "sdk-ruby-ci-#{required_env('GITHUB_RUN_ID')}-#{required_env('GITHUB_RUN_ATTEMPT')}" result = service.create_namespace( Temporalio::Api::Cloud::CloudService::V1::CreateNamespaceRequest.new( spec: Temporalio::Api::Cloud::Namespace::V1::NamespaceSpec.new( @@ -44,7 +36,7 @@ def create( replicas: [Temporalio::Api::Cloud::Namespace::V1::ReplicaSpec.new(region: CLOUD_REGION)], retention_days: 1, mtls_auth: Temporalio::Api::Cloud::Namespace::V1::MtlsAuthSpec.new( - accepted_client_ca: File.binread(required_env(env, 'TEMPORAL_CLOUD_CLIENT_CA_PATH')), + accepted_client_ca: File.binread(required_env('TEMPORAL_CLOUD_CLIENT_CA_PATH')), enabled: true ) ), @@ -54,18 +46,13 @@ def create( namespace = result.namespace raise 'Create namespace response did not include a namespace' if namespace.nil? || namespace.empty? - output.puts("namespace=#{namespace}") - output.flush - wait_for_operation(service, result.async_operation, monotonic:, sleeper:) + File.open(required_env('GITHUB_OUTPUT'), 'a') { |output| output.puts("namespace=#{namespace}") } + wait_for_operation(service, result.async_operation) end # Read the current resource version because Cloud uses optimistic concurrency for deletion. - def delete( - service:, - namespace:, - monotonic: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, - sleeper: ->(duration) { Kernel.sleep(duration) } - ) + def delete(namespace) + service = cloud_service existing = service.get_namespace( Temporalio::Api::Cloud::CloudService::V1::GetNamespaceRequest.new(namespace:) ).namespace @@ -81,20 +68,15 @@ def delete( async_operation_id: SecureRandom.uuid ) ) - wait_for_operation(service, result.async_operation, monotonic:, sleeper:) + wait_for_operation(service, result.async_operation) end # Honor server polling guidance while bounding the overall asynchronous operation. - def wait_for_operation( - service, - operation, - monotonic: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, - sleeper: ->(duration) { Kernel.sleep(duration) } - ) + def wait_for_operation(service, operation) operation_id = operation&.id raise 'Cloud operation response did not include an ID' if operation_id.nil? || operation_id.empty? - deadline = monotonic.call + OPERATION_TIMEOUT_SECONDS + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + OPERATION_TIMEOUT_SECONDS loop do operation = service.get_async_operation( Temporalio::Api::Cloud::CloudService::V1::GetAsyncOperationRequest.new( @@ -109,29 +91,30 @@ def wait_for_operation( raise "Cloud operation #{operation_id} #{state}: #{operation.failure_reason}" end - remaining = deadline - monotonic.call + remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC) raise Timeout::Error, "Timed out waiting for Cloud operation #{operation_id}" if remaining <= 0 duration = operation.check_duration delay = duration ? duration.seconds + (duration.nanos / 1_000_000_000.0) : 10 - sleeper.call([delay, 1].max.clamp(0, remaining)) + minimum_delay = [1, remaining].min + Kernel.sleep(delay.clamp(minimum_delay, remaining)) end end private - def cloud_service(env) + def cloud_service Temporalio::Client::Connection.new( target_host: CLOUD_API_TARGET, - api_key: required_env(env, 'TEMPORAL_CLIENT_CLOUD_API_KEY'), + api_key: required_env('TEMPORAL_CLIENT_CLOUD_API_KEY'), rpc_metadata: { - 'temporal-cloud-api-version' => required_env(env, 'TEMPORAL_CLIENT_CLOUD_API_VERSION') + 'temporal-cloud-api-version' => required_env('TEMPORAL_CLIENT_CLOUD_API_VERSION') } ).cloud_service end - def required_env(env, name) - value = env.fetch(name, '') + def required_env(name) + value = ENV.fetch(name, '') return value unless value.empty? raise "Missing required environment variable #{name}"