yes-core 1.0.0
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +69 -0
- data/lib/yes/core/active_job_serializers/command_group_serializer.rb +29 -0
- data/lib/yes/core/active_job_serializers/dry_struct_serializer.rb +57 -0
- data/lib/yes/core/aggregate/draftable.rb +205 -0
- data/lib/yes/core/aggregate/dsl/attribute_data.rb +37 -0
- data/lib/yes/core/aggregate/dsl/attribute_definer.rb +54 -0
- data/lib/yes/core/aggregate/dsl/attribute_definers/aggregate.rb +36 -0
- data/lib/yes/core/aggregate/dsl/attribute_definers/standard.rb +36 -0
- data/lib/yes/core/aggregate/dsl/class_name_convention.rb +80 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/authorizer.rb +132 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/base.rb +80 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/authorizer.rb +30 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/authorizer_factory.rb +34 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/base.rb +38 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/cerbos_authorizer.rb +114 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/command.rb +70 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/event.rb +88 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/guard_evaluator.rb +84 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/simple_authorizer.rb +50 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/state_updater.rb +46 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/read_model.rb +75 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/read_model_filter.rb +88 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/read_model_serializer.rb +76 -0
- data/lib/yes/core/aggregate/dsl/command_data.rb +54 -0
- data/lib/yes/core/aggregate/dsl/command_definer.rb +263 -0
- data/lib/yes/core/aggregate/dsl/command_shortcut_expander.rb +233 -0
- data/lib/yes/core/aggregate/dsl/constant_resolver.rb +67 -0
- data/lib/yes/core/aggregate/dsl/method_definers/attribute/accessor.rb +28 -0
- data/lib/yes/core/aggregate/dsl/method_definers/attribute/aggregate_accessor.rb +36 -0
- data/lib/yes/core/aggregate/dsl/method_definers/attribute/base.rb +42 -0
- data/lib/yes/core/aggregate/dsl/method_definers/command/base.rb +42 -0
- data/lib/yes/core/aggregate/dsl/method_definers/command/can_command.rb +41 -0
- data/lib/yes/core/aggregate/dsl/method_definers/command/command.rb +50 -0
- data/lib/yes/core/aggregate/has_authorizer.rb +86 -0
- data/lib/yes/core/aggregate/has_read_model.rb +169 -0
- data/lib/yes/core/aggregate/read_model_rebuilder.rb +40 -0
- data/lib/yes/core/aggregate/shared_read_model_rebuilder.rb +158 -0
- data/lib/yes/core/aggregate.rb +404 -0
- data/lib/yes/core/authentication_error.rb +8 -0
- data/lib/yes/core/authorization/cerbos_client_provider.rb +27 -0
- data/lib/yes/core/authorization/command_authorizer.rb +40 -0
- data/lib/yes/core/authorization/command_cerbos_authorizer.rb +182 -0
- data/lib/yes/core/authorization/read_model_authorizer.rb +22 -0
- data/lib/yes/core/authorization/read_models_authorizer.rb +49 -0
- data/lib/yes/core/authorization/read_request_authorizer.rb +32 -0
- data/lib/yes/core/authorization/read_request_cerbos_authorizer.rb +112 -0
- data/lib/yes/core/command.rb +35 -0
- data/lib/yes/core/command_handling/aggregate_tracker.rb +33 -0
- data/lib/yes/core/command_handling/command_executor.rb +171 -0
- data/lib/yes/core/command_handling/command_handler.rb +124 -0
- data/lib/yes/core/command_handling/event_publisher.rb +189 -0
- data/lib/yes/core/command_handling/guard_evaluator.rb +165 -0
- data/lib/yes/core/command_handling/guard_runner.rb +76 -0
- data/lib/yes/core/command_handling/payload_proxy.rb +159 -0
- data/lib/yes/core/command_handling/read_model_recovery_service.rb +264 -0
- data/lib/yes/core/command_handling/read_model_revision_guard.rb +198 -0
- data/lib/yes/core/command_handling/read_model_updater.rb +103 -0
- data/lib/yes/core/command_handling/state_updater.rb +113 -0
- data/lib/yes/core/commands/bus.rb +46 -0
- data/lib/yes/core/commands/group.rb +135 -0
- data/lib/yes/core/commands/group_response.rb +13 -0
- data/lib/yes/core/commands/helper.rb +126 -0
- data/lib/yes/core/commands/notifier.rb +65 -0
- data/lib/yes/core/commands/processor.rb +137 -0
- data/lib/yes/core/commands/response.rb +63 -0
- data/lib/yes/core/commands/stateless/group_handler.rb +186 -0
- data/lib/yes/core/commands/stateless/group_response.rb +15 -0
- data/lib/yes/core/commands/stateless/handler.rb +292 -0
- data/lib/yes/core/commands/stateless/handler_helpers.rb +321 -0
- data/lib/yes/core/commands/stateless/response.rb +14 -0
- data/lib/yes/core/commands/stateless/subject.rb +41 -0
- data/lib/yes/core/commands/validator.rb +28 -0
- data/lib/yes/core/configuration.rb +432 -0
- data/lib/yes/core/data_decryptor.rb +59 -0
- data/lib/yes/core/data_encryptor.rb +60 -0
- data/lib/yes/core/encryption_metadata.rb +33 -0
- data/lib/yes/core/error.rb +14 -0
- data/lib/yes/core/error_messages.rb +37 -0
- data/lib/yes/core/event.rb +222 -0
- data/lib/yes/core/event_class_resolver.rb +40 -0
- data/lib/yes/core/generators/read_models/add_pending_update_tracking_generator.rb +43 -0
- data/lib/yes/core/generators/read_models/templates/add_pending_update_tracking.rb.erb +122 -0
- data/lib/yes/core/generators/read_models/templates/migration.rb.erb +9 -0
- data/lib/yes/core/generators/read_models/update_generator.rb +147 -0
- data/lib/yes/core/jobs/read_model_recovery_job.rb +219 -0
- data/lib/yes/core/middlewares/encryptor.rb +48 -0
- data/lib/yes/core/middlewares/timestamp.rb +29 -0
- data/lib/yes/core/middlewares/with_indifferent_access.rb +22 -0
- data/lib/yes/core/models/application_record.rb +9 -0
- data/lib/yes/core/open_telemetry/otl_span.rb +110 -0
- data/lib/yes/core/open_telemetry/trackable.rb +101 -0
- data/lib/yes/core/payload_store/base.rb +33 -0
- data/lib/yes/core/payload_store/errors.rb +13 -0
- data/lib/yes/core/payload_store/lookup.rb +44 -0
- data/lib/yes/core/process_managers/access_token_client.rb +107 -0
- data/lib/yes/core/process_managers/base.rb +40 -0
- data/lib/yes/core/process_managers/command_runner.rb +109 -0
- data/lib/yes/core/process_managers/service_client.rb +57 -0
- data/lib/yes/core/process_managers/state.rb +118 -0
- data/lib/yes/core/railtie.rb +58 -0
- data/lib/yes/core/read_model/builder.rb +267 -0
- data/lib/yes/core/read_model/event_handler.rb +64 -0
- data/lib/yes/core/read_model/filter.rb +118 -0
- data/lib/yes/core/read_model/filter_query_builder.rb +104 -0
- data/lib/yes/core/serializer.rb +21 -0
- data/lib/yes/core/subscriptions.rb +94 -0
- data/lib/yes/core/test_support/event_helpers.rb +27 -0
- data/lib/yes/core/test_support/jwt_helpers.rb +30 -0
- data/lib/yes/core/test_support/subscriptions_helper.rb +88 -0
- data/lib/yes/core/test_support/test_helper.rb +27 -0
- data/lib/yes/core/test_support.rb +5 -0
- data/lib/yes/core/transaction_details.rb +90 -0
- data/lib/yes/core/type_lookup.rb +88 -0
- data/lib/yes/core/types.rb +110 -0
- data/lib/yes/core/utils/aggregate_shortcuts.rb +164 -0
- data/lib/yes/core/utils/caller_utils.rb +37 -0
- data/lib/yes/core/utils/command_utils.rb +226 -0
- data/lib/yes/core/utils/error_notifier.rb +101 -0
- data/lib/yes/core/utils/event_name_resolver.rb +67 -0
- data/lib/yes/core/utils/exponential_retrier.rb +180 -0
- data/lib/yes/core/utils/hash_utils.rb +63 -0
- data/lib/yes/core/version.rb +7 -0
- data/lib/yes/core.rb +85 -0
- data/lib/yes.rb +0 -0
- metadata +324 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module Authorization
|
|
6
|
+
# Authorizes a collection of read model records by delegating to per-record authorizers.
|
|
7
|
+
class ReadModelsAuthorizer
|
|
8
|
+
NotAuthorized = Class.new(Yes::Core::Error)
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
# @param read_model_name [String] name of the read model
|
|
12
|
+
# @param records [Array<ApplicationRecord>] records to authorize
|
|
13
|
+
# @param auth_data [Hash] authorization data
|
|
14
|
+
# @raise [NotAuthorized] if any records are not authorized
|
|
15
|
+
def call(read_model_name, records, auth_data)
|
|
16
|
+
authorizer = authorizer_for(read_model_name)
|
|
17
|
+
|
|
18
|
+
return unless authorizer
|
|
19
|
+
|
|
20
|
+
unauthorized = []
|
|
21
|
+
records.each do |record|
|
|
22
|
+
authorizer.call(record, auth_data)
|
|
23
|
+
rescue ReadModelAuthorizer::NotAuthorized => e
|
|
24
|
+
unauthorized << {
|
|
25
|
+
message: e.message,
|
|
26
|
+
model_type: record.class.to_s,
|
|
27
|
+
model_id: record.id
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
raise NotAuthorized.new(extra: unauthorized) if unauthorized.any?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# @param read_model_name [String] name of the read model
|
|
37
|
+
# @return [Yes::Core::Authorization::ReadModelAuthorizer, nil] authorizer for read model if existing
|
|
38
|
+
def authorizer_for(read_model_name)
|
|
39
|
+
class_name = "ReadModels::#{read_model_name.classify}::Authorizer"
|
|
40
|
+
|
|
41
|
+
Kernel.const_get(class_name)
|
|
42
|
+
rescue NameError
|
|
43
|
+
nil # defining a per record authorizer is optional
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module Authorization
|
|
6
|
+
# @abstract Read request authorizer base class. Subclass and override call method to implement
|
|
7
|
+
# a custom authorizer.
|
|
8
|
+
class ReadRequestAuthorizer
|
|
9
|
+
NotAuthorized = Class.new(Yes::Core::Error)
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
# Implement this method to authorize a read request.
|
|
13
|
+
# Needs to return true if read request is authorized, otherwise raise NotAuthorized.
|
|
14
|
+
# @param params [Hash] request params to authorize
|
|
15
|
+
# @param auth_data [Hash] authorization data
|
|
16
|
+
# @return [Boolean] true if read request is authorized raises NotAuthorized otherwise
|
|
17
|
+
def call(_params, _auth_data)
|
|
18
|
+
raise NotAuthorized
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# @param auth_data [Hash] authorization data
|
|
24
|
+
# @return [Boolean] true if user is a super admin
|
|
25
|
+
def super_admin?(auth_data)
|
|
26
|
+
Yes::Core.configuration.super_admin_check.call(auth_data)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module Authorization
|
|
6
|
+
# @abstract Read request Cerbos authorizer base class. Subclass and override call method to implement
|
|
7
|
+
# a custom authorizer.
|
|
8
|
+
class ReadRequestCerbosAuthorizer < Yes::Core::Authorization::ReadRequestAuthorizer
|
|
9
|
+
class << self
|
|
10
|
+
include OpenTelemetry::Trackable
|
|
11
|
+
include CerbosClientProvider
|
|
12
|
+
|
|
13
|
+
# Implement this method to authorize a read request.
|
|
14
|
+
# Needs to return true if read request is authorized, otherwise raise NotAuthorized.
|
|
15
|
+
# @param params [Hash] request params to authorize
|
|
16
|
+
# @param auth_data [Hash] authorization data
|
|
17
|
+
# @return [Boolean] true if read request is authorized raises NotAuthorized otherwise
|
|
18
|
+
# @raise [NotAuthorized] if read request is not authorized
|
|
19
|
+
def call(params, auth_data)
|
|
20
|
+
singleton_class.current_span&.add_attributes(
|
|
21
|
+
{ params: params.to_json, auth_data: auth_data.to_json }.stringify_keys
|
|
22
|
+
)
|
|
23
|
+
auth_data = auth_data.with_indifferent_access
|
|
24
|
+
|
|
25
|
+
check_authorization_data(params) unless super_admin?(auth_data)
|
|
26
|
+
|
|
27
|
+
decision = authorize(params, auth_data)
|
|
28
|
+
singleton_class.current_span&.add_event('Cerbos Decision', attributes: { 'decision' => decision.to_json })
|
|
29
|
+
return true if decision.allow_all?
|
|
30
|
+
|
|
31
|
+
raise_unauthorized_error!(params, decision)
|
|
32
|
+
end
|
|
33
|
+
otl_trackable :call, OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Cerbos Authorize Read Request')
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# @param params [Hash]
|
|
38
|
+
# @param decision [Cerbos::Decision] decision from Cerbos
|
|
39
|
+
# raise [NotAuthorized]
|
|
40
|
+
def raise_unauthorized_error!(params, decision)
|
|
41
|
+
msg = "You don't have access to these #{params[:model]}"
|
|
42
|
+
singleton_class.current_span&.status = ::OpenTelemetry::Trace::Status.error(msg)
|
|
43
|
+
|
|
44
|
+
raise self::NotAuthorized.new(msg, extra: { decision: decision.outputs.map(&:value) })
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @param params [Hash] request params to authorize
|
|
48
|
+
# @return [Boolean] true if user is a super admin
|
|
49
|
+
# @raise [NotAuthorized]
|
|
50
|
+
def check_authorization_data(_params)
|
|
51
|
+
raise NotImplementedError, 'You need to implement check_authorization_data'
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def authorize(...)
|
|
55
|
+
cerbos_client.check_resource(**cerbos_payload(...))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @param params [Hash] request params to authorize
|
|
59
|
+
# @param auth_data [Hash] authorization data
|
|
60
|
+
# @return [Hash] payload for Cerbos check_resource
|
|
61
|
+
def cerbos_payload(params, auth_data)
|
|
62
|
+
{
|
|
63
|
+
principal: principal_data(auth_data),
|
|
64
|
+
resource: resource_data(params),
|
|
65
|
+
actions: actions(params),
|
|
66
|
+
include_metadata: Yes::Core.configuration.cerbos_read_authorizer_include_metadata
|
|
67
|
+
}.deep_symbolize_keys.tap { singleton_class.current_span&.set_attribute('cerbos_payload', _1.to_json) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @param params [Hash] request params to authorize
|
|
71
|
+
# @return [Hash] resource data for Cerbos check_resource
|
|
72
|
+
def resource_data(params)
|
|
73
|
+
{
|
|
74
|
+
scope:,
|
|
75
|
+
kind: params[:model],
|
|
76
|
+
id: resource_id(params),
|
|
77
|
+
attributes: resource_attributes(params)
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @param params [Hash]
|
|
82
|
+
# @return [Hash]
|
|
83
|
+
def resource_attributes(_params)
|
|
84
|
+
{}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @param auth_data [Hash] authorization data
|
|
88
|
+
# @return [Hash] principal data for Cerbos check_resource
|
|
89
|
+
def principal_data(auth_data)
|
|
90
|
+
Yes::Core.configuration.cerbos_read_principal_data_builder.call(auth_data)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @return [String] scope for Cerbos check_resource
|
|
94
|
+
def scope
|
|
95
|
+
Rails.application.class.module_parent_name.underscore
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @param params [Hash] request params to authorize
|
|
99
|
+
def actions(_params)
|
|
100
|
+
Yes::Core.configuration.cerbos_read_authorizer_actions
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @param params [Hash] request params to authorize
|
|
104
|
+
# @return [String] resource id for Cerbos check_resource
|
|
105
|
+
def resource_id(params)
|
|
106
|
+
"#{Yes::Core.configuration.cerbos_read_authorizer_resource_id_prefix}#{params[:model]}"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
# Base command class for all commands in the system.
|
|
6
|
+
# Inherits from Dry::Struct for type-safe attribute definitions.
|
|
7
|
+
class Command < Dry::Struct
|
|
8
|
+
# Raised when a command fails validation
|
|
9
|
+
class Invalid < Error; end
|
|
10
|
+
|
|
11
|
+
RESERVED_KEYS = %i[transaction origin batch_id command_id metadata es_encrypted].freeze
|
|
12
|
+
|
|
13
|
+
attribute? :transaction, Types.Instance(TransactionDetails).optional
|
|
14
|
+
attribute? :origin, Types::String.optional
|
|
15
|
+
attribute? :batch_id, Types::String.optional
|
|
16
|
+
attribute? :metadata, Types::Hash.optional
|
|
17
|
+
attribute(:command_id, Types::UUID.default { SecureRandom.uuid })
|
|
18
|
+
|
|
19
|
+
# @param attributes [Hash] constructor parameters
|
|
20
|
+
# @raise [Invalid] if the parameters are invalid
|
|
21
|
+
def self.new(attributes)
|
|
22
|
+
super
|
|
23
|
+
rescue Dry::Struct::Error => e
|
|
24
|
+
raise Invalid.new(extra: attributes), e
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Returns the command payload excluding reserved keys.
|
|
28
|
+
#
|
|
29
|
+
# @return [Hash] command payload as a hash
|
|
30
|
+
def payload
|
|
31
|
+
to_h.except(*RESERVED_KEYS)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module CommandHandling
|
|
6
|
+
# Tracks external aggregate access during command handling
|
|
7
|
+
class AggregateTracker
|
|
8
|
+
# @return [Array<Hash>] List of accessed external aggregates with their revisions
|
|
9
|
+
attr_reader :accessed_external_aggregates
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@accessed_external_aggregates = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Tracks an external aggregate access
|
|
16
|
+
#
|
|
17
|
+
# @param attribute_name [Symbol] The attribute name
|
|
18
|
+
# @param id [String] The aggregate ID
|
|
19
|
+
# @param revision [Integer] The aggregate revision
|
|
20
|
+
# @param context [String] The context name
|
|
21
|
+
# @return [void]
|
|
22
|
+
def track(attribute_name:, id:, revision:, context:)
|
|
23
|
+
accessed_external_aggregates << {
|
|
24
|
+
id:,
|
|
25
|
+
context:,
|
|
26
|
+
name: attribute_name.to_s.camelize,
|
|
27
|
+
revision:
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module CommandHandling
|
|
6
|
+
# Raised when multiple processes attempt to update the same aggregate concurrently
|
|
7
|
+
# This error is thrown when the pending_update_since mechanism detects a conflict
|
|
8
|
+
class ConcurrentUpdateError < Yes::Core::Error
|
|
9
|
+
# Initializes a new ConcurrentUpdateError
|
|
10
|
+
#
|
|
11
|
+
# @param aggregate_class [Class] The aggregate class
|
|
12
|
+
# @param aggregate_id [String] The ID of the aggregate being updated
|
|
13
|
+
# @param original_error [Exception] The underlying database error
|
|
14
|
+
def initialize(aggregate_class:, aggregate_id:, original_error: nil)
|
|
15
|
+
message = build_error_message(aggregate_class, aggregate_id, original_error)
|
|
16
|
+
|
|
17
|
+
super(message, extra: {
|
|
18
|
+
aggregate_class: aggregate_class.name,
|
|
19
|
+
aggregate_id: aggregate_id,
|
|
20
|
+
context: aggregate_class.context,
|
|
21
|
+
stream_name: aggregate_class.aggregate,
|
|
22
|
+
original_error: original_error&.message
|
|
23
|
+
})
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
# Builds the error message
|
|
29
|
+
#
|
|
30
|
+
# @param aggregate_class [Class] The aggregate class
|
|
31
|
+
# @param aggregate_id [String] The aggregate ID
|
|
32
|
+
# @param original_error [Exception, nil] The underlying error if any
|
|
33
|
+
# @return [String] The formatted error message
|
|
34
|
+
def build_error_message(aggregate_class, aggregate_id, original_error)
|
|
35
|
+
context = aggregate_class.context
|
|
36
|
+
stream_name = aggregate_class.aggregate
|
|
37
|
+
|
|
38
|
+
base_message = "Concurrent update detected for #{context}::#{stream_name} with ID #{aggregate_id}. " \
|
|
39
|
+
'Another process is currently updating this aggregate.'
|
|
40
|
+
|
|
41
|
+
if original_error
|
|
42
|
+
"#{base_message} Original error: #{original_error.message}"
|
|
43
|
+
else
|
|
44
|
+
base_message
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Executes commands with retry logic and pending state management
|
|
50
|
+
# Handles the core command execution including guard evaluation, event publishing,
|
|
51
|
+
# and error handling with optimistic concurrency control
|
|
52
|
+
#
|
|
53
|
+
# @example
|
|
54
|
+
# executor = CommandExecutor.new(aggregate)
|
|
55
|
+
# response = executor.call(command, guard_evaluator_class)
|
|
56
|
+
#
|
|
57
|
+
class CommandExecutor
|
|
58
|
+
MAX_RETRIES = 10
|
|
59
|
+
INLINE_RECOVERY_RETRY_THRESHOLD = 5
|
|
60
|
+
|
|
61
|
+
# Initializes a new CommandExecutor
|
|
62
|
+
#
|
|
63
|
+
# @param aggregate [Yes::Core::Aggregate] The aggregate instance to execute commands for
|
|
64
|
+
def initialize(aggregate)
|
|
65
|
+
@aggregate = aggregate
|
|
66
|
+
@read_model = aggregate.read_model if aggregate.class.read_model_enabled?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Executes a command with retry logic and error handling
|
|
70
|
+
#
|
|
71
|
+
# @param cmd [Yes::Core::Command] The command to execute
|
|
72
|
+
# @param command_name [Symbol] The name of the command being executed
|
|
73
|
+
# @param guard_evaluator_class [Class] The guard evaluator class to process the command
|
|
74
|
+
# @param skip_guards [Boolean] Whether to skip guard evaluation (default: false)
|
|
75
|
+
# @return [Yes::Core::Commands::Response] The command response
|
|
76
|
+
def call(cmd, command_name, guard_evaluator_class, skip_guards: false)
|
|
77
|
+
retries = 0
|
|
78
|
+
|
|
79
|
+
begin
|
|
80
|
+
evaluator = GuardRunner.new(aggregate).call(cmd, command_name, guard_evaluator_class, skip_guards:)
|
|
81
|
+
|
|
82
|
+
set_pending_update_state if aggregate.class.read_model_enabled?
|
|
83
|
+
|
|
84
|
+
begin
|
|
85
|
+
event = EventPublisher.new(
|
|
86
|
+
command: cmd,
|
|
87
|
+
aggregate_data: EventPublisher::AggregateEventPublicationData.from_aggregate(aggregate),
|
|
88
|
+
accessed_external_aggregates: evaluator&.accessed_external_aggregates || []
|
|
89
|
+
).call
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
clear_pending_update_state if aggregate.class.read_model_enabled?
|
|
92
|
+
raise e
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
command_response_class(cmd).new(cmd:, event:)
|
|
96
|
+
rescue PgEventstore::WrongExpectedRevisionError => e
|
|
97
|
+
retries += 1
|
|
98
|
+
clear_pending_update_state if aggregate.class.read_model_enabled?
|
|
99
|
+
|
|
100
|
+
retries <= MAX_RETRIES ? retry : raise(e)
|
|
101
|
+
rescue ConcurrentUpdateError => e
|
|
102
|
+
retries += 1
|
|
103
|
+
# Don't clear pending state - another process owns it
|
|
104
|
+
# Sleep with exponential backoff to give the other process time to finish
|
|
105
|
+
sleep([0.01 * (2**(retries - 1)), 1.0].min) if retries <= MAX_RETRIES
|
|
106
|
+
|
|
107
|
+
# After several retries, check if pending state is stuck and attempt recovery
|
|
108
|
+
# This prevents infinite retry loops when a process crashes leaving the flag set
|
|
109
|
+
if aggregate.class.read_model_enabled? && retries >= INLINE_RECOVERY_RETRY_THRESHOLD
|
|
110
|
+
ReadModelRecoveryService.attempt_inline_recovery(read_model, aggregate: aggregate)
|
|
111
|
+
read_model.reload
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
retries <= MAX_RETRIES ? retry : raise(e)
|
|
115
|
+
rescue GuardEvaluator::InvalidTransition,
|
|
116
|
+
GuardEvaluator::NoChangeTransition,
|
|
117
|
+
Yes::Core::Command::Invalid => e
|
|
118
|
+
command_response_class(cmd).new(cmd: cmd, error: e, batch_id: cmd.batch_id)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
attr_reader :aggregate, :read_model
|
|
125
|
+
|
|
126
|
+
# Sets pending update state on read model
|
|
127
|
+
#
|
|
128
|
+
# @return [void]
|
|
129
|
+
# @raise [ConcurrentUpdateError] If another process is already updating
|
|
130
|
+
def set_pending_update_state
|
|
131
|
+
return unless read_model
|
|
132
|
+
|
|
133
|
+
begin
|
|
134
|
+
ActiveRecord::Base.transaction(requires_new: true) do
|
|
135
|
+
read_model.update_column(:pending_update_since, Time.current)
|
|
136
|
+
end
|
|
137
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
138
|
+
raise e unless e.message.include?('Concurrent pending update not allowed')
|
|
139
|
+
|
|
140
|
+
raise ConcurrentUpdateError.new(
|
|
141
|
+
aggregate_class: aggregate.class,
|
|
142
|
+
aggregate_id: read_model.id,
|
|
143
|
+
original_error: e
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Clears pending update state on read model
|
|
149
|
+
#
|
|
150
|
+
# @return [void]
|
|
151
|
+
def clear_pending_update_state
|
|
152
|
+
return unless read_model
|
|
153
|
+
|
|
154
|
+
read_model.update_column(:pending_update_since, nil)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Determines the appropriate response class for the command
|
|
158
|
+
#
|
|
159
|
+
# @param cmd [Yes::Core::Command] The command
|
|
160
|
+
# @return [Class] GroupResponse or Response class
|
|
161
|
+
def command_response_class(cmd)
|
|
162
|
+
if cmd.is_a?(Yes::Core::Commands::Group)
|
|
163
|
+
Yes::Core::Commands::Stateless::GroupResponse
|
|
164
|
+
else
|
|
165
|
+
Yes::Core::Commands::Response
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module CommandHandling
|
|
6
|
+
# Handles the complete command execution flow for aggregates
|
|
7
|
+
# This class orchestrates command preparation, execution, and read model updates
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# handler = CommandHandler.new(aggregate)
|
|
11
|
+
# response = handler.call(:approve_documents, { document_ids: '123', another: 'value' })
|
|
12
|
+
#
|
|
13
|
+
class CommandHandler
|
|
14
|
+
include Yes::Core::OpenTelemetry::Trackable
|
|
15
|
+
|
|
16
|
+
# Initializes a new CommandHandler
|
|
17
|
+
#
|
|
18
|
+
# @param aggregate [Yes::Core::Aggregate] The aggregate instance to handle commands for
|
|
19
|
+
def initialize(aggregate)
|
|
20
|
+
@aggregate = aggregate
|
|
21
|
+
@command_utilities = aggregate.send(:command_utilities)
|
|
22
|
+
@read_model = aggregate.read_model if aggregate.class.read_model_enabled?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Executes a command and updates the aggregate state
|
|
26
|
+
#
|
|
27
|
+
# @param command_name [Symbol] The name of the command to execute
|
|
28
|
+
# @param payload [Hash] The command payload
|
|
29
|
+
# @param guards [Boolean] Whether to evaluate guards (default: true)
|
|
30
|
+
# @param metadata [Hash] Optional custom metadata to add to the event
|
|
31
|
+
# @return [Yes::Core::Commands::Response] The command response
|
|
32
|
+
def call(command_name, payload, guards: true, metadata: nil)
|
|
33
|
+
prepared_payload = prepare_payload(command_name, payload, metadata)
|
|
34
|
+
cmd = command_utilities.build_command(command_name, prepared_payload)
|
|
35
|
+
|
|
36
|
+
guard_evaluator_class = command_utilities.fetch_guard_evaluator_class(command_name)
|
|
37
|
+
|
|
38
|
+
ReadModelRecoveryService.check_and_recover_with_retries(read_model, aggregate:) if aggregate.class.read_model_enabled?
|
|
39
|
+
|
|
40
|
+
response = CommandExecutor.new(aggregate).
|
|
41
|
+
call(cmd, command_name, guard_evaluator_class, skip_guards: !guards)
|
|
42
|
+
|
|
43
|
+
ReadModelUpdater.new(aggregate).call(response.event, prepared_payload, command_name) if aggregate.class.read_model_enabled? && response.success?
|
|
44
|
+
|
|
45
|
+
response
|
|
46
|
+
end
|
|
47
|
+
otl_trackable :call,
|
|
48
|
+
Yes::Core::OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Execute command')
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
attr_reader :aggregate, :command_utilities, :read_model
|
|
53
|
+
|
|
54
|
+
# Prepares the command payload
|
|
55
|
+
#
|
|
56
|
+
# @param command_name [Symbol] The command name
|
|
57
|
+
# @param payload [Hash] The raw payload
|
|
58
|
+
# @param metadata [Hash, nil] Optional custom metadata
|
|
59
|
+
# @return [Hash] The prepared payload
|
|
60
|
+
def prepare_payload(command_name, payload, metadata = nil)
|
|
61
|
+
prepared = command_utilities.prepare_default_payload(
|
|
62
|
+
command_name,
|
|
63
|
+
payload,
|
|
64
|
+
aggregate.class
|
|
65
|
+
)
|
|
66
|
+
prepared = command_utilities.prepare_command_payload(
|
|
67
|
+
command_name,
|
|
68
|
+
prepared,
|
|
69
|
+
aggregate.class
|
|
70
|
+
)
|
|
71
|
+
prepared = command_utilities.prepare_assign_command_payload(
|
|
72
|
+
command_name,
|
|
73
|
+
prepared
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
add_console_origin(prepared)
|
|
77
|
+
add_draft_metadata(prepared) if aggregate.draft?
|
|
78
|
+
add_otl_metadata(prepared)
|
|
79
|
+
add_custom_metadata(prepared, metadata)
|
|
80
|
+
|
|
81
|
+
prepared
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Adds console origin to payload when not already present and running in Rails console
|
|
85
|
+
#
|
|
86
|
+
# @param payload [Hash] The payload to modify
|
|
87
|
+
# @return [void]
|
|
88
|
+
def add_console_origin(payload)
|
|
89
|
+
return if payload[:origin].present?
|
|
90
|
+
|
|
91
|
+
console_origin = Utils::CallerUtils.console_origin
|
|
92
|
+
payload[:origin] = console_origin if console_origin
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Adds draft metadata to payload if aggregate is draft
|
|
96
|
+
#
|
|
97
|
+
# @param payload [Hash] The payload to modify
|
|
98
|
+
# @return [void]
|
|
99
|
+
def add_draft_metadata(payload)
|
|
100
|
+
payload[:metadata] ||= {}
|
|
101
|
+
payload[:metadata][:draft] = true
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def add_otl_metadata(payload)
|
|
105
|
+
return if payload.dig(:metadata, :otl_contexts).blank?
|
|
106
|
+
|
|
107
|
+
payload[:metadata][:otl_contexts][:timestamps][:command_handling_started_at_ms] = (Time.now.utc.to_f * 1000).to_i
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Adds custom metadata to payload
|
|
111
|
+
#
|
|
112
|
+
# @param payload [Hash] The payload to modify
|
|
113
|
+
# @param metadata [Hash, nil] The custom metadata to add
|
|
114
|
+
# @return [void]
|
|
115
|
+
def add_custom_metadata(payload, metadata)
|
|
116
|
+
return if metadata.blank?
|
|
117
|
+
|
|
118
|
+
payload[:metadata] ||= {}
|
|
119
|
+
payload[:metadata].merge!(metadata)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|