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,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module Utils
|
|
6
|
+
# Handles command and handler class operations for aggregates
|
|
7
|
+
#
|
|
8
|
+
# @since 0.1.0
|
|
9
|
+
# @api private
|
|
10
|
+
class CommandUtils
|
|
11
|
+
class CommandNotFoundError < Yes::Core::Error; end
|
|
12
|
+
|
|
13
|
+
ASSIGN_COMMAND_PREFIX = 'assign_'
|
|
14
|
+
|
|
15
|
+
# @param context [String] The context namespace
|
|
16
|
+
# @param aggregate [String] The aggregate name
|
|
17
|
+
# @param aggregate_id [String] The ID of the aggregate
|
|
18
|
+
def initialize(context:, aggregate:, aggregate_id:)
|
|
19
|
+
@context = context
|
|
20
|
+
@aggregate = aggregate
|
|
21
|
+
@aggregate_id = aggregate_id
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Builds a change command instance for the given attribute and payload
|
|
25
|
+
#
|
|
26
|
+
# @param attribute [Symbol] The attribute name
|
|
27
|
+
# @param payload [Hash] The command payload
|
|
28
|
+
# @return [Yes::Core::Command] The instantiated command
|
|
29
|
+
# @raise [RuntimeError] If the command class cannot be found
|
|
30
|
+
def build_attribute_command(attribute, payload)
|
|
31
|
+
build_command(:"change_#{attribute}", payload)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Builds a command instance for the given command name and payload
|
|
35
|
+
#
|
|
36
|
+
# @param command_name [Symbol] The command name
|
|
37
|
+
# @param payload [Hash] The command payload
|
|
38
|
+
# @return [Yes::Core::Command] The instantiated command
|
|
39
|
+
# @raise [RuntimeError] If the command class cannot be found
|
|
40
|
+
def build_command(command_name, payload)
|
|
41
|
+
command_class = fetch_class(command_name, :command)
|
|
42
|
+
command_class.new("#{aggregate.underscore}_id": aggregate_id, **payload)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Fetches the guard evaluator class for a given command name
|
|
46
|
+
#
|
|
47
|
+
# @param name [Symbol] The command name
|
|
48
|
+
# @return [Class] The guard evaluator class
|
|
49
|
+
# @raise [RuntimeError] If the guard evaluator class cannot be found
|
|
50
|
+
def fetch_guard_evaluator_class(name)
|
|
51
|
+
fetch_class(name, :guard_evaluator)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Fetches the state updater class for a given command name
|
|
55
|
+
#
|
|
56
|
+
# @param name [Symbol] The command name
|
|
57
|
+
# @return [Class] The state updater class
|
|
58
|
+
def fetch_state_updater_class(name)
|
|
59
|
+
fetch_class(name, :state_updater)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Builds a PgEventstore::Event instance
|
|
63
|
+
#
|
|
64
|
+
# @param command_name [Symbol] The command name
|
|
65
|
+
# @param payload [Hash] The event payload
|
|
66
|
+
# @param metadata [Hash] Event metadata
|
|
67
|
+
# @return [PgEventstore::Event] The event instance
|
|
68
|
+
def build_event(command_name:, payload:, metadata: {})
|
|
69
|
+
event_class = Yes::Core.configuration.event_classes_for_command(context, aggregate, command_name).first
|
|
70
|
+
event_class.new(
|
|
71
|
+
type: "#{context}::#{aggregate_name_with_draft_suffix(aggregate, metadata)}#{event_class.name.demodulize}",
|
|
72
|
+
data: payload,
|
|
73
|
+
metadata:
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Builds a PgEventstore::Stream instance
|
|
78
|
+
#
|
|
79
|
+
# @param context [String] The context name
|
|
80
|
+
# @param name [String] The stream name
|
|
81
|
+
# @param id [String] The stream ID
|
|
82
|
+
# @return [PgEventstore::Stream] The stream instance
|
|
83
|
+
def build_stream(context: @context, name: @aggregate, id: @aggregate_id, metadata: {})
|
|
84
|
+
PgEventstore::Stream.new(
|
|
85
|
+
context:,
|
|
86
|
+
stream_name: aggregate_name_with_draft_suffix(name, metadata),
|
|
87
|
+
stream_id: id
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Gets the current revision of a stream
|
|
92
|
+
#
|
|
93
|
+
# @param stream [PgEventstore::Stream] The stream to check
|
|
94
|
+
# @return [Integer] The current revision
|
|
95
|
+
def stream_revision(stream)
|
|
96
|
+
PgEventstore.client.read(
|
|
97
|
+
stream,
|
|
98
|
+
options: { direction: 'Backwards', max_count: 1 },
|
|
99
|
+
middlewares: []
|
|
100
|
+
).first&.stream_revision || 0
|
|
101
|
+
rescue PgEventstore::StreamNotFoundError
|
|
102
|
+
:no_stream
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def prepare_assign_command_payload(command_name, payload)
|
|
106
|
+
return payload unless command_name.to_s.starts_with?(ASSIGN_COMMAND_PREFIX)
|
|
107
|
+
|
|
108
|
+
attribute_name = command_name.to_s.split(ASSIGN_COMMAND_PREFIX).last.to_sym
|
|
109
|
+
name_with_id = :"#{attribute_name}_id"
|
|
110
|
+
key = payload.key?(attribute_name) ? attribute_name : name_with_id
|
|
111
|
+
|
|
112
|
+
return payload unless payload[key].is_a?(Yes::Core::Aggregate)
|
|
113
|
+
|
|
114
|
+
payload[name_with_id] = payload.delete(key).id
|
|
115
|
+
payload
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @param event [PgEventstore::Event] The event
|
|
119
|
+
# @param aggregate_class [Class] The aggregate class
|
|
120
|
+
# @return [Symbol] The command name
|
|
121
|
+
# @raise [CommandNotFoundError] If the command is not found
|
|
122
|
+
def command_name_from_event(event, aggregate_class)
|
|
123
|
+
event_name = event.type.split('::').last.sub(event.stream.stream_name.sub(/(Draft|EditTemplate)/, ''), '').underscore
|
|
124
|
+
command = aggregate_class.commands.values.find { _1.event_name.to_s == event_name }
|
|
125
|
+
raise CommandNotFoundError, "Command not found for event #{event_name}" unless command
|
|
126
|
+
|
|
127
|
+
command.name
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Prepares the payload for a command
|
|
131
|
+
#
|
|
132
|
+
# @param command_name [Symbol] The command name
|
|
133
|
+
# @param payload [Hash] The command payload
|
|
134
|
+
# @param aggregate_class [Class] The aggregate class
|
|
135
|
+
# @return [Hash] The prepared payload
|
|
136
|
+
def prepare_command_payload(command_name, payload, aggregate_class)
|
|
137
|
+
return append_locale_param(command_name, payload, aggregate_class) if payload.is_a?(Hash)
|
|
138
|
+
|
|
139
|
+
payload_attributes = aggregate_class.commands[command_name].payload_attributes.except(:locale)
|
|
140
|
+
raise 'Payload attributes must be a Hash with a single key (not including locale key)' if payload_attributes.length > 1
|
|
141
|
+
|
|
142
|
+
append_locale_param(command_name, { payload_attributes.keys.first => payload }, aggregate_class)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Prefills default values in the payload if they are not provided
|
|
146
|
+
#
|
|
147
|
+
# @param command_name [Symbol] The command name
|
|
148
|
+
# @param payload [Hash] The command payload
|
|
149
|
+
# @param aggregate_class [Class] The aggregate class
|
|
150
|
+
# @return [Hash] The prepared payload
|
|
151
|
+
def prepare_default_payload(command_name, payload, aggregate_class)
|
|
152
|
+
return payload unless payload.is_a?(Hash)
|
|
153
|
+
|
|
154
|
+
attributes_with_defaults = aggregate_class.commands[command_name].payload_attributes.select do |_key, value|
|
|
155
|
+
value.is_a?(Hash) && value.key?(:default)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
return payload unless attributes_with_defaults.any?
|
|
159
|
+
|
|
160
|
+
additions = {}
|
|
161
|
+
attributes_with_defaults.each do |key, value|
|
|
162
|
+
next if payload.key?(key)
|
|
163
|
+
|
|
164
|
+
default = value[:default]
|
|
165
|
+
additions[key] = default.respond_to?(:call) ? default.call : default
|
|
166
|
+
end
|
|
167
|
+
payload.merge(additions)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
attr_reader :context, :aggregate, :aggregate_id
|
|
173
|
+
|
|
174
|
+
# Fetches a class based on the command name and type
|
|
175
|
+
#
|
|
176
|
+
# @param command [Symbol] The command name
|
|
177
|
+
# @param type [Symbol] The type of class to fetch (:command or :guard_evaluator)
|
|
178
|
+
# @return [Class] The requested class
|
|
179
|
+
# @raise [RuntimeError] If the requested class cannot be found
|
|
180
|
+
def fetch_class(command, type)
|
|
181
|
+
klass = Yes::Core.configuration.aggregate_class(context, aggregate, command, type)
|
|
182
|
+
raise "#{type.to_s.tr('_', ' ').capitalize} class not found for #{command}" unless klass
|
|
183
|
+
|
|
184
|
+
klass
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Removes the '_id' suffix from an attribute name
|
|
188
|
+
#
|
|
189
|
+
# @param attribute [Symbol, String] The attribute name that might contain an '_id' suffix
|
|
190
|
+
# @return [String] The attribute name without the '_id' suffix
|
|
191
|
+
# @example
|
|
192
|
+
# command_name(:user_id) # => "user"
|
|
193
|
+
# command_name("company_id") # => "company"
|
|
194
|
+
# command_name(:name) # => "name"
|
|
195
|
+
def command_name(attribute)
|
|
196
|
+
attribute.to_s.sub('_id', '')
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Adds locale param to payload if required and not present
|
|
200
|
+
#
|
|
201
|
+
# @param command_name [Symbol] The command name
|
|
202
|
+
# @param payload [Hash] The command payload
|
|
203
|
+
# @param aggregate_class [Class] The aggregate class
|
|
204
|
+
# @return [Hash] The prepared payload
|
|
205
|
+
def append_locale_param(command_name, payload, aggregate_class)
|
|
206
|
+
return payload if payload.key?(:locale)
|
|
207
|
+
return payload unless aggregate_class.commands[command_name].payload_attributes.key?(:locale)
|
|
208
|
+
|
|
209
|
+
payload.merge(locale: I18n.locale.to_s)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Builds the aggregate name with the draft suffix
|
|
213
|
+
#
|
|
214
|
+
# @param aggregate_name [String] The name of the aggregate
|
|
215
|
+
# @param metadata [Hash] The command metadata
|
|
216
|
+
# @return [String] The stream name
|
|
217
|
+
def aggregate_name_with_draft_suffix(aggregate_name, metadata = {})
|
|
218
|
+
return "#{aggregate_name}Draft" if metadata&.dig(:draft)
|
|
219
|
+
return "#{aggregate_name}EditTemplate" if metadata&.dig(:edit_template_command)
|
|
220
|
+
|
|
221
|
+
aggregate_name
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module Utils
|
|
6
|
+
# Notifies about errors via a pluggable error reporter.
|
|
7
|
+
#
|
|
8
|
+
# By default, errors are logged via Rails.logger. To integrate with an external
|
|
9
|
+
# error tracking service (e.g. Sentry), configure the error reporter:
|
|
10
|
+
#
|
|
11
|
+
# Yes::Core.configure do |config|
|
|
12
|
+
# config.error_reporter = ->(error, context:) { Sentry.capture_exception(error, extra: context) }
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# The error_reporter must respond to #call(error, context:).
|
|
16
|
+
class ErrorNotifier
|
|
17
|
+
# @param event [PgEventstore::Event]
|
|
18
|
+
# @return [void]
|
|
19
|
+
def invalid_event_data(event)
|
|
20
|
+
msg = 'Event with invalid data found in stream'
|
|
21
|
+
data = { event: event.to_json }
|
|
22
|
+
|
|
23
|
+
logger&.info("#{msg} data: #{data}")
|
|
24
|
+
capture_message(msg, extra: data)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param error [Error]
|
|
28
|
+
# @return [void]
|
|
29
|
+
def payload_extraction_failed(error)
|
|
30
|
+
msg = 'Large payload extraction failed'
|
|
31
|
+
|
|
32
|
+
logger&.info("#{msg} data: #{error.extra}")
|
|
33
|
+
capture_message(msg, extra: error.extra)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param error [Error]
|
|
37
|
+
# @return [void]
|
|
38
|
+
def decryption_key_error(error)
|
|
39
|
+
data = {
|
|
40
|
+
encryptor_response: error.encryptor_response,
|
|
41
|
+
event: error.event
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
logger&.info("#{error.message} data: #{data}")
|
|
45
|
+
capture_message(error.message, extra: data)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param error [Error]
|
|
49
|
+
# @return [void]
|
|
50
|
+
def decryption_error(error)
|
|
51
|
+
logger&.info("#{error.message} data: #{error.extra}")
|
|
52
|
+
capture_message(error.message, extra: error.extra)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @param message [String]
|
|
56
|
+
# @param event [PgEventstore::Event]
|
|
57
|
+
# @return [void]
|
|
58
|
+
def event_handler_not_defined(message, event)
|
|
59
|
+
data = { event: event.to_json }
|
|
60
|
+
|
|
61
|
+
logger&.info("#{message} data: #{data}")
|
|
62
|
+
capture_message(message, extra: data) if ENV['CAPTURE_EVENTSOURCING_ERRORS'] == 'true'
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [void]
|
|
66
|
+
def missing_payload_store_client_error
|
|
67
|
+
msg = 'Missing PayloadStore Client. Please configure it.'
|
|
68
|
+
|
|
69
|
+
logger&.info(msg)
|
|
70
|
+
capture_message(msg)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @param error [Exception]
|
|
74
|
+
# @param extra [Hash, nil]
|
|
75
|
+
# @return [void]
|
|
76
|
+
def notify(error, extra: nil)
|
|
77
|
+
error_reporter&.call(error, context: extra || {})
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
attr_reader :error_reporter, :logger
|
|
83
|
+
|
|
84
|
+
def initialize(logger: Yes::Core.configuration.logger)
|
|
85
|
+
@error_reporter = Yes::Core.configuration.error_reporter
|
|
86
|
+
@logger = logger
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @param msg [String]
|
|
90
|
+
# @param options [Hash]
|
|
91
|
+
# @return [void]
|
|
92
|
+
def capture_message(msg, options = {})
|
|
93
|
+
return unless error_reporter
|
|
94
|
+
|
|
95
|
+
error = StandardError.new(msg)
|
|
96
|
+
error_reporter.call(error, context: options.fetch(:extra, {}))
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module Utils
|
|
6
|
+
# Resolves event names from command names by converting command verbs to their past tense form
|
|
7
|
+
# @example
|
|
8
|
+
# EventNameResolver.call('ChangeLocation') # => :location_changed
|
|
9
|
+
# EventNameResolver.call('AddUser') # => :user_added
|
|
10
|
+
class EventNameResolver
|
|
11
|
+
# @return [Hash<String, String>] Mapping of command verbs to their corresponding event (past tense) forms
|
|
12
|
+
COMMAND_TO_EVENT_VERBS = {
|
|
13
|
+
'Activate' => 'Activated',
|
|
14
|
+
'Add' => 'Added',
|
|
15
|
+
'Approve' => 'Approved',
|
|
16
|
+
'Archive' => 'Archived',
|
|
17
|
+
'Assign' => 'Assigned',
|
|
18
|
+
'Cancel' => 'Cancelled',
|
|
19
|
+
'Change' => 'Changed',
|
|
20
|
+
'Close' => 'Closed',
|
|
21
|
+
'Complete' => 'Completed',
|
|
22
|
+
'Confirm' => 'Confirmed',
|
|
23
|
+
'Deactivate' => 'Deactivated',
|
|
24
|
+
'Delete' => 'Deleted',
|
|
25
|
+
'Disable' => 'Disabled',
|
|
26
|
+
'Enable' => 'Enabled',
|
|
27
|
+
'Fail' => 'Failed',
|
|
28
|
+
'Open' => 'Opened',
|
|
29
|
+
'Publish' => 'Published',
|
|
30
|
+
'Reactivate' => 'Reactivated',
|
|
31
|
+
'Reject' => 'Rejected',
|
|
32
|
+
'Remove' => 'Removed',
|
|
33
|
+
'Reopen' => 'Reopened',
|
|
34
|
+
'Resolve' => 'Resolved',
|
|
35
|
+
'Restore' => 'Restored',
|
|
36
|
+
'Start' => 'Started',
|
|
37
|
+
'Stop' => 'Stopped',
|
|
38
|
+
'Submit' => 'Submitted',
|
|
39
|
+
'Unassign' => 'Unassigned',
|
|
40
|
+
'Unpublish' => 'Unpublished',
|
|
41
|
+
'Update' => 'Updated'
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
# Converts a command name to its corresponding event name
|
|
45
|
+
# @param command_name [String, Symbol] The name of the command to convert
|
|
46
|
+
# @return [Symbol, nil] The converted event name as an underscored symbol, or nil if no conversion is possible
|
|
47
|
+
# @example
|
|
48
|
+
# EventNameResolver.call('ChangeLocation') # => :location_changed
|
|
49
|
+
# EventNameResolver.call(:add_user) # => :user_added
|
|
50
|
+
# EventNameResolver.call('InvalidCommand') # => nil
|
|
51
|
+
def self.call(command_name)
|
|
52
|
+
normalized_command_name = command_name.to_s.camelize
|
|
53
|
+
COMMAND_TO_EVENT_VERBS.each do |command_verb, event_verb|
|
|
54
|
+
next unless normalized_command_name.start_with?(command_verb)
|
|
55
|
+
|
|
56
|
+
# Extract the subject (e.g. "Location" from "ChangeLocation")
|
|
57
|
+
subject = normalized_command_name.delete_prefix(command_verb)
|
|
58
|
+
# Return subject + verb (e.g. "LocationChanged")
|
|
59
|
+
return "#{subject}#{event_verb}".underscore.to_sym
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module Utils
|
|
6
|
+
# Generic exponential backoff retry utility
|
|
7
|
+
# Provides configurable retry logic with exponential backoff, jitter, and timeout
|
|
8
|
+
class ExponentialRetrier
|
|
9
|
+
# Default configuration constants
|
|
10
|
+
DEFAULT_MAX_RETRIES = 6
|
|
11
|
+
DEFAULT_BASE_SLEEP_TIME = 0.1
|
|
12
|
+
DEFAULT_MAX_SLEEP_TIME = 5.0
|
|
13
|
+
DEFAULT_JITTER_FACTOR = 0.1
|
|
14
|
+
DEFAULT_TIMEOUT = 30
|
|
15
|
+
|
|
16
|
+
# Error raised when retry fails after maximum attempts
|
|
17
|
+
class RetryFailedError < StandardError; end
|
|
18
|
+
|
|
19
|
+
# Error raised when timeout is exceeded
|
|
20
|
+
class TimeoutError < StandardError; end
|
|
21
|
+
|
|
22
|
+
# Configuration for the retrier
|
|
23
|
+
#
|
|
24
|
+
# @param max_retries [Integer] Maximum number of retry attempts
|
|
25
|
+
# @param base_sleep_time [Float] Base sleep time in seconds for exponential backoff
|
|
26
|
+
# @param max_sleep_time [Float] Maximum sleep time to prevent excessive waiting
|
|
27
|
+
# @param jitter_factor [Float] Jitter factor for randomizing sleep times
|
|
28
|
+
# @param timeout [Integer] Maximum time to wait before timing out
|
|
29
|
+
# @param logger [Object] Logger instance for retry information
|
|
30
|
+
def initialize(
|
|
31
|
+
max_retries: DEFAULT_MAX_RETRIES,
|
|
32
|
+
base_sleep_time: DEFAULT_BASE_SLEEP_TIME,
|
|
33
|
+
max_sleep_time: DEFAULT_MAX_SLEEP_TIME,
|
|
34
|
+
jitter_factor: DEFAULT_JITTER_FACTOR,
|
|
35
|
+
timeout: DEFAULT_TIMEOUT,
|
|
36
|
+
logger: nil
|
|
37
|
+
)
|
|
38
|
+
@max_retries = max_retries
|
|
39
|
+
@base_sleep_time = base_sleep_time
|
|
40
|
+
@max_sleep_time = max_sleep_time
|
|
41
|
+
@jitter_factor = jitter_factor
|
|
42
|
+
@timeout = timeout
|
|
43
|
+
@logger = logger || (defined?(Rails) ? Rails.logger : nil)
|
|
44
|
+
@start_time = Time.current
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Executes the retry logic with exponential backoff
|
|
48
|
+
#
|
|
49
|
+
# @param condition_check [Proc] Block that returns true when condition is met
|
|
50
|
+
# @param action [Proc] Block to execute when condition is met
|
|
51
|
+
# @param failure_message [String] Custom failure message for RetryFailedError
|
|
52
|
+
# @param timeout_message [String] Custom timeout message for TimeoutError
|
|
53
|
+
# @yield Alternative to action parameter - block to execute when condition is met
|
|
54
|
+
# @return [Object] Result of the action/block execution
|
|
55
|
+
# @raise [RetryFailedError] When condition is not met after maximum retries
|
|
56
|
+
# @raise [TimeoutError] When timeout is exceeded
|
|
57
|
+
def call(condition_check:, action: nil, failure_message: nil, timeout_message: nil, &block)
|
|
58
|
+
action_block = action || block
|
|
59
|
+
raise ArgumentError, 'Either action parameter or block must be provided' unless action_block
|
|
60
|
+
|
|
61
|
+
attempts = 0
|
|
62
|
+
total_wait_time = 0
|
|
63
|
+
|
|
64
|
+
loop do
|
|
65
|
+
check_timeout!(timeout_message)
|
|
66
|
+
|
|
67
|
+
if condition_check.call
|
|
68
|
+
log_success(attempts, total_wait_time) if attempts.positive?
|
|
69
|
+
return action_block.call
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
attempts += 1
|
|
73
|
+
if attempts >= max_retries
|
|
74
|
+
log_failure(attempts, total_wait_time)
|
|
75
|
+
raise RetryFailedError, failure_message || default_failure_message(attempts)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
sleep_time = sleep_with_backoff(attempts)
|
|
79
|
+
total_wait_time += sleep_time
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
attr_reader :max_retries, :base_sleep_time, :max_sleep_time, :jitter_factor,
|
|
86
|
+
:timeout, :logger, :start_time
|
|
87
|
+
|
|
88
|
+
# Checks if the timeout has been exceeded
|
|
89
|
+
#
|
|
90
|
+
# @param custom_message [String] Custom timeout message
|
|
91
|
+
# @raise [TimeoutError] When timeout is exceeded
|
|
92
|
+
def check_timeout!(custom_message = nil)
|
|
93
|
+
elapsed_time = Time.current - start_time
|
|
94
|
+
|
|
95
|
+
return unless elapsed_time > timeout
|
|
96
|
+
|
|
97
|
+
message = custom_message || "Timeout after #{elapsed_time.round(2)}s"
|
|
98
|
+
raise TimeoutError, message
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Sleeps with exponential backoff and jitter
|
|
102
|
+
#
|
|
103
|
+
# @param attempt_number [Integer] Current attempt number
|
|
104
|
+
# @return [Float] The actual sleep time used
|
|
105
|
+
def sleep_with_backoff(attempt_number)
|
|
106
|
+
sleep_time = calculate_sleep_time(attempt_number)
|
|
107
|
+
log_retry(attempt_number, sleep_time)
|
|
108
|
+
sleep(sleep_time)
|
|
109
|
+
sleep_time
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Calculates sleep time with exponential backoff, jitter, and maximum cap
|
|
113
|
+
#
|
|
114
|
+
# @param attempt_number [Integer] Current attempt number
|
|
115
|
+
# @return [Float] Sleep time in seconds
|
|
116
|
+
def calculate_sleep_time(attempt_number)
|
|
117
|
+
# Calculate base exponential backoff
|
|
118
|
+
base_sleep = base_sleep_time * (2**(attempt_number - 1))
|
|
119
|
+
|
|
120
|
+
# Add jitter to prevent thundering herd
|
|
121
|
+
jitter = base_sleep * jitter_factor * rand(-1.0..1.0)
|
|
122
|
+
sleep_with_jitter = base_sleep + jitter
|
|
123
|
+
|
|
124
|
+
# Cap at maximum sleep time
|
|
125
|
+
[sleep_with_jitter, max_sleep_time].min
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Default failure message when none is provided
|
|
129
|
+
#
|
|
130
|
+
# @param attempts [Integer] Number of attempts made
|
|
131
|
+
# @return [String] Default failure message
|
|
132
|
+
def default_failure_message(attempts)
|
|
133
|
+
"Retry failed after #{attempts} attempts"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Logs successful retry after multiple attempts
|
|
137
|
+
#
|
|
138
|
+
# @param attempts [Integer] Number of attempts made
|
|
139
|
+
# @param total_wait_time [Float] Total time spent waiting
|
|
140
|
+
# @return [void]
|
|
141
|
+
def log_success(attempts, total_wait_time)
|
|
142
|
+
return unless logger
|
|
143
|
+
|
|
144
|
+
logger.info(
|
|
145
|
+
"ExponentialRetrier succeeded after #{attempts} attempts " \
|
|
146
|
+
"(waited #{total_wait_time.round(2)}s)"
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Logs failure after maximum retries
|
|
151
|
+
#
|
|
152
|
+
# @param attempts [Integer] Number of attempts made
|
|
153
|
+
# @param total_wait_time [Float] Total time spent waiting
|
|
154
|
+
# @return [void]
|
|
155
|
+
def log_failure(attempts, total_wait_time)
|
|
156
|
+
return unless logger
|
|
157
|
+
|
|
158
|
+
logger.error(
|
|
159
|
+
"ExponentialRetrier failed after #{attempts} attempts " \
|
|
160
|
+
"(waited #{total_wait_time.round(2)}s)"
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Logs retry attempt
|
|
165
|
+
#
|
|
166
|
+
# @param attempt_number [Integer] Current attempt number
|
|
167
|
+
# @param sleep_time [Float] Time to sleep before retry
|
|
168
|
+
# @return [void]
|
|
169
|
+
def log_retry(attempt_number, sleep_time)
|
|
170
|
+
return unless logger&.debug?
|
|
171
|
+
|
|
172
|
+
logger.debug(
|
|
173
|
+
"ExponentialRetrier retry #{attempt_number}/#{max_retries}. " \
|
|
174
|
+
"Sleeping #{sleep_time.round(3)}s"
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module Utils
|
|
6
|
+
# Utility class for deep hash operations
|
|
7
|
+
class HashUtils
|
|
8
|
+
class << self
|
|
9
|
+
# Returns a deep duplicate of a hash, recursively duplicating nested hashes.
|
|
10
|
+
#
|
|
11
|
+
# @param hash [Hash] the hash to deep duplicate
|
|
12
|
+
# @return [Hash, Object] the deep duplicated hash, or the original object if not a Hash
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# HashUtils.deep_dup({ a: { b: 1 } })
|
|
16
|
+
# # => { a: { b: 1 } } (a completely independent copy)
|
|
17
|
+
def deep_dup(hash)
|
|
18
|
+
return hash unless hash.instance_of?(Hash)
|
|
19
|
+
|
|
20
|
+
dupl = hash.dup
|
|
21
|
+
dupl.each { |k, v| dupl[k] = v.instance_of?(Hash) ? deep_dup(v) : v }
|
|
22
|
+
dupl
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Returns a hash with the keys flattened
|
|
26
|
+
#
|
|
27
|
+
# @param obj [Hash, Array] the object to flatten
|
|
28
|
+
# @param prefix [String] the key to use as a prefix for the keys in the hash
|
|
29
|
+
# @param memo [Hash] the hash to store the flattened keys and values
|
|
30
|
+
# @return [Hash] the flattened hash
|
|
31
|
+
#
|
|
32
|
+
# @example
|
|
33
|
+
# HashUtils.deep_flatten_hash({ name: 'A', otl_contexts: { root: { attr: 10, available: true } } })
|
|
34
|
+
# => {"name"=>"A", "otl_contexts.root.attr"=>10, "otl_contexts.root.available"=>true}
|
|
35
|
+
def deep_flatten_hash(obj, prefix = nil, memo = {})
|
|
36
|
+
case obj
|
|
37
|
+
when Hash
|
|
38
|
+
obj.each do |key, value|
|
|
39
|
+
case [key, value]
|
|
40
|
+
in Hash, Array
|
|
41
|
+
memo[deep_flatten_hash(key)] = memo[deep_flatten_hash(value)]
|
|
42
|
+
in String | Symbol, Hash
|
|
43
|
+
deep_flatten_hash(value, prefix ? "#{prefix}.#{key}" : key.to_s, memo)
|
|
44
|
+
in String | Symbol, Array
|
|
45
|
+
memo[key.to_s] = deep_flatten_hash(value)
|
|
46
|
+
in Array, _
|
|
47
|
+
memo[deep_flatten_hash(key)] = deep_flatten_hash(value)
|
|
48
|
+
else
|
|
49
|
+
memo[prefix ? "#{prefix}.#{key}" : key.to_s] = value
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
memo
|
|
53
|
+
when Array
|
|
54
|
+
obj.map { deep_flatten_hash(_1) }
|
|
55
|
+
else
|
|
56
|
+
obj
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|