Repository navigation
Add extstore runner #645
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Add extstore runner #645
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ | |
| require 'temporalio/converters/payload_converter' | ||
| require 'temporalio/converters/raw_value' | ||
| require 'temporalio/error' | ||
| require 'temporalio/internal/external_storage_references' | ||
|
|
||
| module Temporalio | ||
| module Converters | ||
|
|
@@ -56,6 +57,9 @@ def to_payload(value, hint: nil) | |
| # @raise [ConverterNotFound] If no converter found for the encoding. | ||
| def from_payload(payload, hint: nil) | ||
| return nil unless payload | ||
| # A reference that reaches conversion was never retrieved, and would otherwise be converted into the reference | ||
| # message itself | ||
| raise Error::ExternalStorageNotConfiguredError if Internal::ExternalStorageReferences.reference?(payload) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think this should be raised from within the payload converter if we are already checking incoming payloads. |
||
|
|
||
| encoding = payload.metadata['encoding'] | ||
| raise EncodingNotSet, 'Missing payload encoding' unless encoding | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,129 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| require 'temporalio/api' | ||
| require 'temporalio/cancellation' | ||
| require 'temporalio/converters/storage_driver_claim' | ||
| require 'temporalio/converters/storage_driver_limiter' | ||
| require 'temporalio/converters/storage_driver_retrieve_context' | ||
| require 'temporalio/converters/storage_driver_select_context' | ||
| require 'temporalio/converters/storage_driver_store_context' | ||
| require 'temporalio/error' | ||
| require 'temporalio/internal/external_storage_references' | ||
|
|
||
| module Temporalio | ||
| module Internal | ||
| # Stores large payloads in external storage and fetches them back. | ||
| # | ||
| # Storing replaces each payload at or above the size threshold with a reference to what a driver stored. Retrieving | ||
| # swaps references back for the payloads they point at. Payloads are expected to already be codec-encoded when | ||
| # stored, and are returned still encoded when retrieved. | ||
| # | ||
| # Each driver gets one batched request per call, and requests to different drivers run one after another. The | ||
| # configured concurrency limits are not enforced and the driver target is always nil. | ||
| # | ||
| # @!visibility private | ||
| class ExternalStorageRunner | ||
| # @param storage [Converters::ExternalStorage, nil] Configured storage. When nil, storing does nothing and | ||
| # retrieving raises if it finds a reference, so a reference is never mistaken for an ordinary payload. | ||
| def initialize(storage) | ||
| @storage = storage | ||
| end | ||
|
|
||
| # @param payloads [Array<Api::Common::V1::Payload>] Codec-encoded payloads. Not mutated. | ||
| # @return [Array<Api::Common::V1::Payload>] Payloads with every eligible one replaced by a reference. | ||
| def store(payloads) | ||
| storage = @storage | ||
| return payloads unless storage | ||
|
|
||
| select_context = Converters::StorageDriverSelectContext.new(target: nil, cancellation: Cancellation.new) | ||
|
|
||
| # Driver => [[index, serialized payload], ...] for each payload that driver stores | ||
| payloads_by_driver = Hash.new { |hash, driver| hash[driver] = [] } | ||
| payloads.each_with_index do |payload, index| | ||
| serialized = payload.to_proto | ||
| next if serialized.bytesize < storage.payload_size_threshold | ||
|
|
||
| driver = storage.driver_selector.call(select_context, payload) | ||
| next unless driver | ||
|
|
||
| # Checked by identity, so a driver built to look like a registered one is rejected. | ||
| unless storage.driver(driver.name).equal?(driver) | ||
| raise Error, "External storage driver selector returned a driver that is not registered: #{driver.inspect}" | ||
| end | ||
|
|
||
| payloads_by_driver[driver] << [index, serialized] | ||
| end | ||
|
|
||
| result = payloads.dup | ||
| payloads_by_driver.each do |driver, indexes_and_serialized| | ||
| context = Converters::StorageDriverStoreContext.new( | ||
| target: nil, cancellation: Cancellation.new, limiter: UNBOUNDED_LIMITER | ||
| ) | ||
| # Drivers get copies, since a driver may keep a payload after returning and the SDK modifies some payloads in | ||
| # place | ||
| copies = indexes_and_serialized.map { |_index, serialized| Api::Common::V1::Payload.decode(serialized) } | ||
| claims = driver.store(context, copies) | ||
| check_result_count(driver, 'store', indexes_and_serialized.size, claims) | ||
|
|
||
| indexes_and_serialized.zip(claims) do |(index, serialized), claim| | ||
| result[index] = ExternalStorageReferences.create_reference_payload( | ||
| driver_name: driver.name, claim_data: claim.claim_data, original_size_bytes: serialized.bytesize | ||
| ) | ||
| end | ||
| end | ||
| result | ||
| end | ||
|
|
||
| # @param payloads [Array<Api::Common::V1::Payload>] Payloads that may contain references. Not mutated. | ||
| # @return [Array<Api::Common::V1::Payload>] Payloads with every reference replaced by what it points at. | ||
| # @raise [Error::ExternalStorageNotConfiguredError] If there is a reference but no storage is configured. | ||
| def retrieve(payloads) | ||
| storage = @storage | ||
| # Driver => [[index, claim], ...] for each reference that driver stored. Every driver is looked up here, so an | ||
| # unregistered name fails before any request is made. | ||
| claims_by_driver = Hash.new { |hash, driver| hash[driver] = [] } | ||
| payloads.each_with_index do |payload, index| | ||
| reference = ExternalStorageReferences.parse_reference(payload) | ||
| next unless reference | ||
| raise Error::ExternalStorageNotConfiguredError unless storage | ||
|
|
||
| driver = storage.driver(reference.driver_name) | ||
| raise Error, "No external storage driver registered with name '#{reference.driver_name}'" unless driver | ||
|
|
||
| claims_by_driver[driver] << [index, Converters::StorageDriverClaim.new(claim_data: reference.claim_data.to_h)] | ||
| end | ||
|
|
||
| result = payloads.dup | ||
| claims_by_driver.each do |driver, indexes_and_claims| | ||
| context = Converters::StorageDriverRetrieveContext.new( | ||
| cancellation: Cancellation.new, limiter: UNBOUNDED_LIMITER | ||
| ) | ||
| retrieved = driver.retrieve(context, indexes_and_claims.map { |_index, claim| claim }) | ||
| check_result_count(driver, 'retrieve', indexes_and_claims.size, retrieved) | ||
|
|
||
| indexes_and_claims.zip(retrieved) { |(index, _claim), payload| result[index] = payload } | ||
| end | ||
| result | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def check_result_count(driver, operation, requested, returned) | ||
| return if returned.is_a?(Array) && returned.size == requested | ||
|
|
||
| raise Error, "External storage driver '#{driver.name}' #{operation} was given #{requested} item(s) but " \ | ||
| "returned #{returned.is_a?(Array) ? returned.size : returned.inspect}" | ||
| end | ||
|
|
||
| # Grants every permit immediately, since concurrency limits are not enforced yet. | ||
| class UnboundedLimiter < Converters::StorageDriverLimiter | ||
| def permit(_item) | ||
| yield | ||
| end | ||
| end | ||
|
|
||
| UNBOUNDED_LIMITER = UnboundedLimiter.new | ||
| private_constant :UnboundedLimiter, :UNBOUNDED_LIMITER | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -2,6 +2,7 @@ | |||||
|
|
||||||
| require 'temporalio/api/payload_visitor' | ||||||
| require 'temporalio/error' | ||||||
| require 'temporalio/internal/external_storage_runner' | ||||||
| require 'temporalio/internal/worker/workflow_instance' | ||||||
| require 'temporalio/scoped_logger' | ||||||
| require 'temporalio/workflow' | ||||||
|
|
@@ -81,18 +82,30 @@ def initialize( | |||||
| @executor = workflow_executor | ||||||
|
|
||||||
| payload_codec = data_converter.payload_codec | ||||||
| external_storage = data_converter.external_storage | ||||||
| @workflow_payload_codec_thread_pool = workflow_payload_codec_thread_pool | ||||||
| if !Fiber.current_scheduler && payload_codec && !@workflow_payload_codec_thread_pool | ||||||
| raise ArgumentError, 'Must have workflow payload codec thread pool if providing codec and not using fibers' | ||||||
| if !Fiber.current_scheduler && (payload_codec || external_storage) && !@workflow_payload_codec_thread_pool | ||||||
| raise ArgumentError, 'Must have workflow payload codec thread pool if providing codec or external ' \ | ||||||
|
cconstable marked this conversation as resolved.
Resolved
|
||||||
| 'storage and not using fibers' | ||||||
| end | ||||||
|
|
||||||
| # If there is a payload codec, we need to build encoding and decoding visitors | ||||||
| if payload_codec | ||||||
| # If there is a payload codec or external storage, we need to build encoding and decoding visitors. Storage | ||||||
| # comes after the codec when encoding and before it when decoding. | ||||||
| if payload_codec || external_storage | ||||||
|
cconstable marked this conversation as resolved.
Resolved
|
||||||
| storage_runner = ExternalStorageRunner.new(external_storage) if external_storage | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This needs to run uncoditionally because the TMPRL1105 check is only validated within the ExternalStorageRunner, which already seems to pivot on whether external storage is specified or not: https://github.com/temporalio/sdk-ruby/pull/645/changes#diff-88fd4bb7950e2f50a029311a8939650241bd2dd0dac0db4fcc9bdef13345505bR87 |
||||||
| @payload_encoding_visitor = Api::PayloadVisitor.new(skip_search_attributes: true) do |payload_or_payloads| | ||||||
| apply_codec_on_payload_visit(payload_or_payloads) { |payloads| payload_codec.encode(payloads) } | ||||||
| replace_payloads_on_visit(payload_or_payloads) do |payloads| | ||||||
| payloads = payload_codec.encode(payloads) if payload_codec | ||||||
| payloads = storage_runner.store(payloads.to_a) if storage_runner | ||||||
|
cconstable marked this conversation as resolved.
Resolved
|
||||||
| payloads | ||||||
| end | ||||||
| end | ||||||
| @payload_decoding_visitor = Api::PayloadVisitor.new(skip_search_attributes: true) do |payload_or_payloads| | ||||||
| apply_codec_on_payload_visit(payload_or_payloads) { |payloads| payload_codec.decode(payloads) } | ||||||
| replace_payloads_on_visit(payload_or_payloads) do |payloads| | ||||||
| payloads = storage_runner.retrieve(payloads.to_a) if storage_runner | ||||||
| payloads = payload_codec.decode(payloads) if payload_codec | ||||||
| payloads | ||||||
| end | ||||||
| end | ||||||
| end | ||||||
|
|
||||||
|
|
@@ -173,22 +186,41 @@ def on_shutdown_complete | |||||
|
|
||||||
| private | ||||||
|
|
||||||
| # Decoding and encoding run in a thread pool that only warns about errors, so a failure must complete the | ||||||
| # activation as failed. Otherwise the run waits forever for a completion that never comes. | ||||||
|
|
||||||
| def decode_activation(runner, activation) | ||||||
| @payload_decoding_visitor.run(activation) | ||||||
| runner.apply_workflow_activation_decoded(workflow_worker: self, activation:) | ||||||
| rescue Exception => e # rubocop:disable Lint/RescueException | ||||||
| fail_activation(runner, activation.run_id, e) | ||||||
| end | ||||||
|
|
||||||
| def encode_activation_completion(runner, activation_completion) | ||||||
| @payload_encoding_visitor.run(activation_completion) | ||||||
| runner.apply_workflow_activation_complete(workflow_worker: self, activation_completion:, encoded: true) | ||||||
| rescue Exception => e # rubocop:disable Lint/RescueException | ||||||
| fail_activation(runner, activation_completion.run_id, e) | ||||||
| end | ||||||
|
|
||||||
| def fail_activation(runner, run_id, error) | ||||||
| activation_completion = WorkflowInstance.new_completion_with_failure( | ||||||
| run_id:, | ||||||
| error:, | ||||||
| failure_converter: @state.data_converter.failure_converter, | ||||||
| payload_converter: @state.data_converter.payload_converter | ||||||
| ) | ||||||
| runner.apply_workflow_activation_complete(workflow_worker: self, activation_completion:, encoded: true) | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
The failure should be marked as needing encoding. It looks like it is handled at https://github.com/temporalio/sdk-ruby/blob/v1.10.1/temporalio/lib/temporalio/internal/worker/workflow_worker.rb#L153 |
||||||
| end | ||||||
|
|
||||||
| def apply_codec_on_payload_visit(payload_or_payloads, &) | ||||||
| def replace_payloads_on_visit(payload_or_payloads, &) | ||||||
| case payload_or_payloads | ||||||
| when Temporalio::Api::Common::V1::Payload | ||||||
| new_payloads = yield [payload_or_payloads] | ||||||
| payload_or_payloads.metadata = new_payloads.first.metadata | ||||||
| payload_or_payloads.data = new_payloads.first.data | ||||||
| # External storage records the original size of a stored payload here | ||||||
| payload_or_payloads.external_payloads.replace(new_payloads.first.external_payloads.to_a) | ||||||
| when Enumerable | ||||||
| payload_or_payloads.replace(yield payload_or_payloads) # steep:ignore | ||||||
| else | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mark this as private visibility until we are sure we want customers to use it.