temporal-ruby 0.0.1.pre.pre1 → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -0
  3. data/README.md +23 -3
  4. data/lib/gen/temporal/api/command/v1/message_pb.rb +1 -1
  5. data/lib/gen/temporal/api/enums/v1/common_pb.rb +7 -0
  6. data/lib/gen/temporal/api/errordetails/v1/message_pb.rb +5 -6
  7. data/lib/gen/temporal/api/version/v1/message_pb.rb +19 -8
  8. data/lib/gen/temporal/api/workflowservice/v1/request_response_pb.rb +19 -8
  9. data/lib/gen/temporal/api/workflowservice/v1/service_services_pb.rb +0 -3
  10. data/lib/temporal.rb +104 -0
  11. data/lib/temporal/activity/context.rb +5 -1
  12. data/lib/temporal/activity/poller.rb +26 -9
  13. data/lib/temporal/activity/task_processor.rb +33 -20
  14. data/lib/temporal/client/converter/base.rb +35 -0
  15. data/lib/temporal/client/converter/composite.rb +49 -0
  16. data/lib/temporal/client/converter/payload/bytes.rb +30 -0
  17. data/lib/temporal/client/converter/payload/json.rb +28 -0
  18. data/lib/temporal/client/converter/payload/nil.rb +27 -0
  19. data/lib/temporal/client/grpc_client.rb +102 -27
  20. data/lib/temporal/client/retryer.rb +49 -0
  21. data/lib/temporal/client/serializer.rb +2 -0
  22. data/lib/temporal/client/serializer/cancel_timer.rb +2 -2
  23. data/lib/temporal/client/serializer/complete_workflow.rb +6 -4
  24. data/lib/temporal/client/serializer/continue_as_new.rb +37 -0
  25. data/lib/temporal/client/serializer/fail_workflow.rb +2 -2
  26. data/lib/temporal/client/serializer/failure.rb +4 -2
  27. data/lib/temporal/client/serializer/record_marker.rb +6 -4
  28. data/lib/temporal/client/serializer/request_activity_cancellation.rb +2 -2
  29. data/lib/temporal/client/serializer/retry_policy.rb +24 -0
  30. data/lib/temporal/client/serializer/schedule_activity.rb +8 -20
  31. data/lib/temporal/client/serializer/start_child_workflow.rb +9 -20
  32. data/lib/temporal/client/serializer/start_timer.rb +2 -2
  33. data/lib/temporal/concerns/payloads.rb +51 -0
  34. data/lib/temporal/configuration.rb +31 -4
  35. data/lib/temporal/error_handler.rb +11 -0
  36. data/lib/temporal/errors.rb +24 -0
  37. data/lib/temporal/execution_options.rb +9 -1
  38. data/lib/temporal/json.rb +3 -1
  39. data/lib/temporal/logger.rb +17 -0
  40. data/lib/temporal/metadata.rb +11 -3
  41. data/lib/temporal/metadata/activity.rb +15 -2
  42. data/lib/temporal/metadata/workflow.rb +8 -0
  43. data/lib/temporal/metadata/workflow_task.rb +11 -0
  44. data/lib/temporal/retry_policy.rb +6 -9
  45. data/lib/temporal/saga/concern.rb +1 -1
  46. data/lib/temporal/testing.rb +1 -0
  47. data/lib/temporal/testing/future_registry.rb +1 -1
  48. data/lib/temporal/testing/local_activity_context.rb +1 -1
  49. data/lib/temporal/testing/local_workflow_context.rb +38 -14
  50. data/lib/temporal/testing/scheduled_workflows.rb +75 -0
  51. data/lib/temporal/testing/temporal_override.rb +35 -7
  52. data/lib/temporal/testing/workflow_override.rb +6 -1
  53. data/lib/temporal/version.rb +1 -1
  54. data/lib/temporal/worker.rb +28 -10
  55. data/lib/temporal/workflow.rb +8 -2
  56. data/lib/temporal/workflow/command.rb +3 -0
  57. data/lib/temporal/workflow/context.rb +40 -5
  58. data/lib/temporal/workflow/errors.rb +39 -0
  59. data/lib/temporal/workflow/executor.rb +1 -1
  60. data/lib/temporal/workflow/future.rb +18 -6
  61. data/lib/temporal/workflow/history/event.rb +1 -3
  62. data/lib/temporal/workflow/history/event_target.rb +4 -0
  63. data/lib/temporal/workflow/history/window.rb +1 -1
  64. data/lib/temporal/workflow/poller.rb +41 -13
  65. data/lib/temporal/workflow/replay_aware_logger.rb +4 -4
  66. data/lib/temporal/workflow/state_manager.rb +33 -52
  67. data/lib/temporal/workflow/task_processor.rb +41 -11
  68. metadata +21 -9
  69. data/lib/temporal/client/serializer/payload.rb +0 -25
@@ -0,0 +1,11 @@
1
+ module Temporal
2
+ module ErrorHandler
3
+ def self.handle(error, metadata: nil)
4
+ Temporal.configuration.error_handlers.each do |handler|
5
+ handler.call(error, metadata: metadata)
6
+ rescue StandardError => e
7
+ Temporal.logger.error("Error handler failed", { error: e.inspect })
8
+ end
9
+ end
10
+ end
11
+ end
@@ -5,6 +5,10 @@ module Temporal
5
5
  # Superclass for errors specific to Temporal worker itself
6
6
  class InternalError < Error; end
7
7
 
8
+ # Indicates a non-deterministic workflow execution, might be due to
9
+ # a non-deterministic workflow implementation or the gem's bug
10
+ class NonDeterministicWorkflowError < InternalError; end
11
+
8
12
  # Superclass for misconfiguration/misuse on the client (user) side
9
13
  class ClientError < Error; end
10
14
 
@@ -21,6 +25,25 @@ module Temporal
21
25
  class ApiError < Error; end
22
26
 
23
27
  class NotFoundFailure < ApiError; end
28
+
29
+ # Superclass for system errors raised when retrieving a workflow result on the
30
+ # client, but the workflow failed remotely.
31
+ class WorkflowError < Error; end
32
+
33
+ class WorkflowTimedOut < WorkflowError; end
34
+ class WorkflowTerminated < WorkflowError; end
35
+ class WorkflowCanceled < WorkflowError; end
36
+
37
+ # Errors where the workflow run didn't complete but not an error for the whole workflow.
38
+ class WorkflowRunError < Error; end
39
+ class WorkflowRunContinuedAsNew < WorkflowRunError
40
+ attr_reader :new_run_id
41
+ def initialize(new_run_id:)
42
+ super
43
+ @new_run_id = new_run_id
44
+ end
45
+ end
46
+
24
47
  class WorkflowExecutionAlreadyStartedFailure < ApiError
25
48
  attr_reader :run_id
26
49
 
@@ -35,4 +58,5 @@ module Temporal
35
58
  class NamespaceAlreadyExistsFailure < ApiError; end
36
59
  class CancellationAlreadyRequestedFailure < ApiError; end
37
60
  class QueryFailedFailure < ApiError; end
61
+
38
62
  end
@@ -12,7 +12,7 @@ module Temporal
12
12
  @timeouts = options[:timeouts] || {}
13
13
  @headers = options[:headers] || {}
14
14
 
15
- if object.singleton_class.included_modules.include?(Concerns::Executable)
15
+ if has_executable_concern?(object)
16
16
  @namespace ||= object.namespace
17
17
  @task_queue ||= object.task_queue
18
18
  @retry_policy ||= object.retry_policy
@@ -31,5 +31,13 @@ module Temporal
31
31
  def task_list
32
32
  @task_queue
33
33
  end
34
+
35
+ private
36
+
37
+ def has_executable_concern?(object)
38
+ object.singleton_class.included_modules.include?(Concerns::Executable)
39
+ rescue TypeError
40
+ false
41
+ end
34
42
  end
35
43
  end
data/lib/temporal/json.rb CHANGED
@@ -4,7 +4,9 @@ require 'oj'
4
4
  module Temporal
5
5
  module JSON
6
6
  OJ_OPTIONS = {
7
- mode: :object
7
+ mode: :object,
8
+ # use ruby's built-in serialization. If nil, OJ seems to default to ~15 decimal places of precision
9
+ float_precision: 0
8
10
  }.freeze
9
11
 
10
12
  def self.serialize(value)
@@ -0,0 +1,17 @@
1
+ require 'logger'
2
+
3
+ module Temporal
4
+ class Logger < ::Logger
5
+ SEVERITIES = %i[debug info warn error fatal unknown].freeze
6
+
7
+ SEVERITIES.each do |severity|
8
+ define_method severity do |message, data = {}|
9
+ super(message.to_s + ' ' + Oj.dump(data, mode: :strict))
10
+ end
11
+ end
12
+
13
+ def log(severity, message, data = {})
14
+ add(severity, message.to_s + ' ' + Oj.dump(data, mode: :strict))
15
+ end
16
+ end
17
+ end
@@ -2,6 +2,7 @@ require 'temporal/errors'
2
2
  require 'temporal/metadata/activity'
3
3
  require 'temporal/metadata/workflow'
4
4
  require 'temporal/metadata/workflow_task'
5
+ require 'temporal/concerns/payloads'
5
6
 
6
7
  module Temporal
7
8
  module Metadata
@@ -10,6 +11,8 @@ module Temporal
10
11
  WORKFLOW_TYPE = :workflow
11
12
 
12
13
  class << self
14
+ include Concerns::Payloads
15
+
13
16
  def generate(type, data, namespace = nil)
14
17
  case type
15
18
  when ACTIVITY_TYPE
@@ -26,7 +29,11 @@ module Temporal
26
29
  private
27
30
 
28
31
  def headers(fields)
29
- fields.transform_values { |v| v[:data] }
32
+ result = {}
33
+ fields.each do |field, payload|
34
+ result[field] = from_payload(payload)
35
+ end
36
+ result
30
37
  end
31
38
 
32
39
  def activity_metadata_from(task, namespace)
@@ -39,7 +46,8 @@ module Temporal
39
46
  workflow_run_id: task.workflow_execution.run_id,
40
47
  workflow_id: task.workflow_execution.workflow_id,
41
48
  workflow_name: task.workflow_type.name,
42
- headers: headers(task.header&.fields.to_h)
49
+ headers: headers(task.header&.fields),
50
+ heartbeat_details: from_details_payloads(task.heartbeat_details)
43
51
  )
44
52
  end
45
53
 
@@ -60,7 +68,7 @@ module Temporal
60
68
  name: event.workflow_type.name,
61
69
  run_id: event.original_execution_run_id,
62
70
  attempt: event.attempt,
63
- headers: headers(event.header&.fields.to_h)
71
+ headers: headers(event.header&.fields)
64
72
  )
65
73
  end
66
74
  end
@@ -3,9 +3,9 @@ require 'temporal/metadata/base'
3
3
  module Temporal
4
4
  module Metadata
5
5
  class Activity < Base
6
- attr_reader :namespace, :id, :name, :task_token, :attempt, :workflow_run_id, :workflow_id, :workflow_name, :headers
6
+ attr_reader :namespace, :id, :name, :task_token, :attempt, :workflow_run_id, :workflow_id, :workflow_name, :headers, :heartbeat_details
7
7
 
8
- def initialize(namespace:, id:, name:, task_token:, attempt:, workflow_run_id:, workflow_id:, workflow_name:, headers: {})
8
+ def initialize(namespace:, id:, name:, task_token:, attempt:, workflow_run_id:, workflow_id:, workflow_name:, headers: {}, heartbeat_details:)
9
9
  @namespace = namespace
10
10
  @id = id
11
11
  @name = name
@@ -15,6 +15,7 @@ module Temporal
15
15
  @workflow_id = workflow_id
16
16
  @workflow_name = workflow_name
17
17
  @headers = headers
18
+ @heartbeat_details = heartbeat_details
18
19
 
19
20
  freeze
20
21
  end
@@ -22,6 +23,18 @@ module Temporal
22
23
  def activity?
23
24
  true
24
25
  end
26
+
27
+ def to_h
28
+ {
29
+ 'namespace' => namespace,
30
+ 'workflow_id' => workflow_id,
31
+ 'workflow_name' => workflow_name,
32
+ 'workflow_run_id' => workflow_run_id,
33
+ 'activity_id' => id,
34
+ 'activity_name' => name,
35
+ 'attempt' => attempt
36
+ }
37
+ end
25
38
  end
26
39
  end
27
40
  end
@@ -17,6 +17,14 @@ module Temporal
17
17
  def workflow?
18
18
  true
19
19
  end
20
+
21
+ def to_h
22
+ {
23
+ 'workflow_name' => name,
24
+ 'workflow_run_id' => run_id,
25
+ 'attempt' => attempt
26
+ }
27
+ end
20
28
  end
21
29
  end
22
30
  end
@@ -20,6 +20,17 @@ module Temporal
20
20
  def workflow_task?
21
21
  true
22
22
  end
23
+
24
+ def to_h
25
+ {
26
+ 'namespace' => namespace,
27
+ 'workflow_task_id' => id,
28
+ 'workflow_name' => workflow_name,
29
+ 'workflow_id' => workflow_id,
30
+ 'workflow_run_id' => workflow_run_id,
31
+ 'attempt' => attempt
32
+ }
33
+ end
23
34
  end
24
35
  end
25
36
  end
@@ -1,25 +1,22 @@
1
1
  require 'temporal/errors'
2
2
 
3
3
  module Temporal
4
+ # See https://docs.temporal.io/docs/go/retries/ for go documentation of equivalent concepts.
4
5
  class RetryPolicy < Struct.new(:interval, :backoff, :max_interval, :max_attempts,
5
- :expiration_interval, :non_retriable_errors, keyword_init: true)
6
+ :non_retriable_errors, keyword_init: true)
6
7
 
7
8
  class InvalidRetryPolicy < ClientError; end
8
9
 
9
10
  def validate!
10
- unless interval && backoff
11
- raise InvalidRetryPolicy, 'interval and backoff must be set'
11
+ unless max_attempts == 1 || (interval && backoff)
12
+ raise InvalidRetryPolicy, 'interval and backoff must be set if max_attempts != 1'
12
13
  end
13
14
 
14
- unless max_attempts || expiration_interval
15
- raise InvalidRetryPolicy, 'max_attempts or expiration_interval must be set'
16
- end
17
-
18
- unless [interval, max_interval, expiration_interval].compact.all? { |arg| arg.is_a?(Integer) }
15
+ unless [interval, max_interval].compact.all? { |arg| arg.is_a?(Integer) }
19
16
  raise InvalidRetryPolicy, 'All intervals must be specified in whole seconds'
20
17
  end
21
18
 
22
- unless [interval, max_interval, expiration_interval].compact.all? { |arg| arg > 0 }
19
+ unless [interval, max_interval].compact.all? { |arg| arg > 0 }
23
20
  raise InvalidRetryPolicy, 'All intervals must be greater than 0'
24
21
  end
25
22
  end
@@ -11,7 +11,7 @@ module Temporal
11
11
 
12
12
  Result.new(true)
13
13
  rescue StandardError => error # TODO: is there a need for a specialized error here?
14
- logger.error("Saga execution aborted: #{error.inspect}")
14
+ logger.error("Saga execution aborted", { error: error.inspect })
15
15
  logger.debug(error.backtrace.join("\n"))
16
16
 
17
17
  saga.compensate
@@ -1,5 +1,6 @@
1
1
  require 'temporal/testing/temporal_override'
2
2
  require 'temporal/testing/workflow_override'
3
+ require 'temporal/testing/scheduled_workflows'
3
4
 
4
5
  module Temporal
5
6
  module Testing
@@ -16,7 +16,7 @@ module Temporal
16
16
  end
17
17
 
18
18
  def fail(token, error)
19
- store[token].fail(error.class.name, error.message)
19
+ store[token].fail(error)
20
20
  end
21
21
 
22
22
  private
@@ -10,7 +10,7 @@ module Temporal
10
10
  end
11
11
 
12
12
  def heartbeat(details = nil)
13
- raise NotImplementedError, 'not yet available for testing'
13
+ # behavior is not yet testable in local mode
14
14
  end
15
15
  end
16
16
  end
@@ -9,21 +9,30 @@ require 'temporal/workflow/history/event_target'
9
9
  module Temporal
10
10
  module Testing
11
11
  class LocalWorkflowContext
12
- attr_reader :headers
12
+ attr_reader :metadata
13
13
 
14
- def initialize(execution, workflow_id, run_id, disabled_releases, headers = {})
14
+ def initialize(execution, workflow_id, run_id, disabled_releases, metadata)
15
15
  @last_event_id = 0
16
16
  @execution = execution
17
17
  @run_id = run_id
18
18
  @workflow_id = workflow_id
19
19
  @disabled_releases = disabled_releases
20
- @headers = headers
20
+ @metadata = metadata
21
+ @completed = false
22
+ end
23
+
24
+ def completed?
25
+ @completed
21
26
  end
22
27
 
23
28
  def logger
24
29
  Temporal.logger
25
30
  end
26
31
 
32
+ def headers
33
+ metadata.headers
34
+ end
35
+
27
36
  def has_release?(change_name)
28
37
  !disabled_releases.include?(change_name.to_s)
29
38
  end
@@ -48,17 +57,25 @@ module Temporal
48
57
  workflow_run_id: run_id,
49
58
  workflow_id: workflow_id,
50
59
  workflow_name: nil, # not yet used, but will be in the future
51
- headers: execution_options.headers
60
+ headers: execution_options.headers,
61
+ heartbeat_details: nil
52
62
  )
53
63
  context = LocalActivityContext.new(metadata)
54
64
 
55
- result = activity_class.execute_in_context(context, input)
56
-
57
- if context.async?
58
- execution.register_future(context.async_token, future)
65
+ begin
66
+ result = activity_class.execute_in_context(context, input)
67
+ rescue StandardError => e
68
+ # Capture any failure from running the activity into the future
69
+ # instead of raising immediately in order to match the behavior of
70
+ # running against a Temporal server.
71
+ future.fail(e)
59
72
  else
60
- # Fulfil the future straigt away for non-async activities
61
- future.set(result)
73
+ if context.async?
74
+ execution.register_future(context.async_token, future)
75
+ else
76
+ # Fulfill the future straight away for non-async activities
77
+ future.set(result)
78
+ end
62
79
  end
63
80
 
64
81
  future
@@ -66,11 +83,11 @@ module Temporal
66
83
 
67
84
  def execute_activity!(activity_class, *input, **args)
68
85
  future = execute_activity(activity_class, *input, **args)
69
- result = future.get
86
+ result_or_exception = future.get
70
87
 
71
- raise future.exception if future.failed?
88
+ raise result_or_exception if future.failed?
72
89
 
73
- result
90
+ result_or_exception
74
91
  end
75
92
 
76
93
  def execute_local_activity(activity_class, *input, **args)
@@ -88,7 +105,8 @@ module Temporal
88
105
  workflow_run_id: run_id,
89
106
  workflow_id: workflow_id,
90
107
  workflow_name: nil, # not yet used, but will be in the future
91
- headers: execution_options.headers
108
+ headers: execution_options.headers,
109
+ heartbeat_details: nil
92
110
  )
93
111
  context = LocalActivityContext.new(metadata)
94
112
 
@@ -131,10 +149,12 @@ module Temporal
131
149
  end
132
150
 
133
151
  def complete(result = nil)
152
+ completed!
134
153
  result
135
154
  end
136
155
 
137
156
  def fail(exception)
157
+ completed!
138
158
  raise exception
139
159
  end
140
160
 
@@ -169,6 +189,10 @@ module Temporal
169
189
 
170
190
  attr_reader :execution, :run_id, :workflow_id, :disabled_releases
171
191
 
192
+ def completed!
193
+ @completed = true
194
+ end
195
+
172
196
  def next_event_id
173
197
  @last_event_id += 1
174
198
  @last_event_id
@@ -0,0 +1,75 @@
1
+ module Temporal
2
+ module Testing
3
+ class WorkflowIDNotScheduled < ClientError; end
4
+
5
+ # When Temporal.schedule_workflow is called in a test in local mode, we defer the execution and do
6
+ # not do it automatically.
7
+ # You can execute them or inspect their cron schedules using this module.
8
+ module ScheduledWorkflows
9
+ def self.execute(workflow_id:)
10
+ Private::Store.execute(workflow_id: workflow_id)
11
+ end
12
+
13
+ def self.execute_all
14
+ Private::Store.execute_all
15
+ end
16
+
17
+ # For someone who wants to assert that the schedule is what they expect.
18
+ # Populated by Temporal.schedule_workflow
19
+ # format: { <workflow_id>: <cron schedule string>, ... }
20
+ def self.cron_schedules
21
+ Private::Store.schedules
22
+ end
23
+
24
+ def self.clear_all
25
+ Private::Store.clear_all
26
+ end
27
+
28
+ module Private
29
+ module Store
30
+ class << self
31
+
32
+ def schedules
33
+ @schedules ||= {}.freeze
34
+ end
35
+
36
+ def add(workflow_id:, cron_schedule:, executor_lambda:)
37
+ new_schedules = schedules.dup
38
+ new_schedules[workflow_id] = cron_schedule
39
+ @schedules = new_schedules.freeze
40
+
41
+ new_scheduled_executions = scheduled_executions.dup
42
+ new_scheduled_executions[workflow_id] = executor_lambda
43
+ @scheduled_executions = new_scheduled_executions.freeze
44
+ end
45
+
46
+ def clear_all
47
+ @scheduled_executions = {}.freeze
48
+ @schedules = {}.freeze
49
+ end
50
+
51
+ def execute(workflow_id:)
52
+ unless scheduled_executions.key?(workflow_id)
53
+ raise Temporal::Testing::WorkflowIDNotScheduled,
54
+ "There is no workflow with id #{workflow_id} that was scheduled with Temporal.schedule_workflow.\n"\
55
+ "Options: #{scheduled_executions.keys}"
56
+ end
57
+
58
+ scheduled_executions[workflow_id].call
59
+ end
60
+
61
+ def execute_all
62
+ scheduled_executions.values.each(&:call)
63
+ end
64
+
65
+ private
66
+
67
+ def scheduled_executions
68
+ @scheduled_executions ||= {}.freeze
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end