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,264 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module CommandHandling
|
|
6
|
+
# Service for recovering read models that are stuck in pending state
|
|
7
|
+
# This handles cases where the process was interrupted after publishing an event
|
|
8
|
+
# but before updating the read model
|
|
9
|
+
class ReadModelRecoveryService
|
|
10
|
+
# Default timeout after which a read model is considered stuck
|
|
11
|
+
DEFAULT_STUCK_TIMEOUT = 1.minute
|
|
12
|
+
|
|
13
|
+
# Recovery attempt result
|
|
14
|
+
RecoveryResult = Struct.new(:success, :read_model, :error_message, keyword_init: true)
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
include Yes::Core::OpenTelemetry::Trackable
|
|
18
|
+
|
|
19
|
+
# Finds and recovers all stuck read models
|
|
20
|
+
# @param stuck_timeout [ActiveSupport::Duration] Time after which a model is considered stuck
|
|
21
|
+
# @param batch_size [Integer] Number of read models to process at once
|
|
22
|
+
# @return [Array<RecoveryResult>] Results of recovery attempts
|
|
23
|
+
def recover_all_stuck_read_models(stuck_timeout: DEFAULT_STUCK_TIMEOUT, batch_size: 100)
|
|
24
|
+
results = []
|
|
25
|
+
|
|
26
|
+
find_stuck_read_models_with_aggregates(stuck_timeout:, batch_size:).each do |entry|
|
|
27
|
+
result = recover_read_model(
|
|
28
|
+
entry[:read_model],
|
|
29
|
+
aggregate_class: entry[:aggregate_class],
|
|
30
|
+
is_draft: entry[:is_draft]
|
|
31
|
+
)
|
|
32
|
+
results << result
|
|
33
|
+
|
|
34
|
+
log_recovery_result(result)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
results
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Recovers a single read model
|
|
41
|
+
# @param read_model [ActiveRecord::Base] The read model to recover
|
|
42
|
+
# @param aggregate_class [Class] The aggregate class to use for recovery
|
|
43
|
+
# @param is_draft [Boolean] Flag indicating if this is a draft aggregate
|
|
44
|
+
# @return [RecoveryResult] Result of the recovery attempt
|
|
45
|
+
def recover_read_model(read_model, aggregate_class:, is_draft: false)
|
|
46
|
+
# Use advisory lock to prevent concurrent recovery attempts
|
|
47
|
+
lock_key = "read_model_recovery_#{read_model.class.name}_#{read_model.id}"
|
|
48
|
+
|
|
49
|
+
with_advisory_lock(lock_key) do
|
|
50
|
+
# Re-check if still stuck after acquiring lock
|
|
51
|
+
read_model.reload
|
|
52
|
+
|
|
53
|
+
return RecoveryResult.new(success: true, read_model:, error_message: 'Already recovered') unless read_model.pending_update_since?
|
|
54
|
+
|
|
55
|
+
# Instantiate aggregate with proper parameters
|
|
56
|
+
aggregate_id = determine_aggregate_id(read_model)
|
|
57
|
+
|
|
58
|
+
aggregate = if is_draft
|
|
59
|
+
aggregate_class.new(aggregate_id, draft: true)
|
|
60
|
+
else
|
|
61
|
+
aggregate_class.new(aggregate_id)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
latest_event = aggregate.latest_event
|
|
65
|
+
|
|
66
|
+
# Reapply the update using ReadModelUpdater
|
|
67
|
+
updater = ReadModelUpdater.new(aggregate)
|
|
68
|
+
updater.call(latest_event, latest_event.data)
|
|
69
|
+
|
|
70
|
+
RecoveryResult.new(success: true, read_model:)
|
|
71
|
+
end
|
|
72
|
+
rescue ActiveRecord::ActiveRecordError => e
|
|
73
|
+
# Database errors during recovery
|
|
74
|
+
RecoveryResult.new(
|
|
75
|
+
success: false,
|
|
76
|
+
read_model:,
|
|
77
|
+
error_message: "Database error during recovery: #{e.message}"
|
|
78
|
+
)
|
|
79
|
+
rescue PgEventstore::Error => e
|
|
80
|
+
# Event store errors during recovery
|
|
81
|
+
RecoveryResult.new(
|
|
82
|
+
success: false,
|
|
83
|
+
read_model:,
|
|
84
|
+
error_message: "Event store error during recovery: #{e.message}"
|
|
85
|
+
)
|
|
86
|
+
rescue StandardError => e
|
|
87
|
+
# Unexpected errors - log them for debugging
|
|
88
|
+
Rails.logger.error("Unexpected error recovering read model #{read_model.class.name}##{read_model.id}: #{e.class.name}")
|
|
89
|
+
RecoveryResult.new(
|
|
90
|
+
success: false,
|
|
91
|
+
read_model:,
|
|
92
|
+
error_message: "Unexpected error: #{e.class.name} - #{e.message}"
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Checks if a read model needs recovery and attempts it with retries
|
|
97
|
+
# @param read_model [ActiveRecord::Base] The read model to check
|
|
98
|
+
# @param aggregate [Yes::Core::Aggregate] Aggregate instance to use for recovery
|
|
99
|
+
# @param threshold [ActiveSupport::Duration] Time threshold for recovery (default: 2 seconds)
|
|
100
|
+
# @param max_retries [Integer] Maximum number of retry attempts (default: 10)
|
|
101
|
+
# @return [void]
|
|
102
|
+
# @raise [Yes::Core::Utils::ExponentialRetrier::RetryFailedError] If recovery fails after max retries
|
|
103
|
+
# @raise [Yes::Core::Utils::ExponentialRetrier::TimeoutError] If recovery times out
|
|
104
|
+
def check_and_recover_with_retries(read_model, aggregate:, threshold: 2.seconds, max_retries: 10)
|
|
105
|
+
return unless read_model.respond_to?(:pending_update_since)
|
|
106
|
+
|
|
107
|
+
retrier = Yes::Core::Utils::ExponentialRetrier.new(
|
|
108
|
+
max_retries: max_retries,
|
|
109
|
+
base_sleep_time: 0.1,
|
|
110
|
+
max_sleep_time: 1.0,
|
|
111
|
+
timeout: 30,
|
|
112
|
+
logger: Rails.logger
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
retrier.call(
|
|
116
|
+
condition_check: -> { attempt_recovery_if_pending(read_model, threshold, aggregate:) },
|
|
117
|
+
failure_message: "Could not recover pending read model #{read_model.class.name}##{read_model.id}",
|
|
118
|
+
timeout_message: "Timeout waiting for read model recovery #{read_model.class.name}##{read_model.id}"
|
|
119
|
+
) do
|
|
120
|
+
# Success - either not pending or recovered
|
|
121
|
+
true
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
otl_trackable(
|
|
126
|
+
:check_and_recover_with_retries,
|
|
127
|
+
Yes::Core::OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Check and Recover Readmodel with Retries')
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Attempts inline recovery during command execution retry loops
|
|
131
|
+
# This method is designed to be called from CommandExecutor when stuck in retry loops
|
|
132
|
+
# @param read_model [ActiveRecord::Base] The read model to check and recover
|
|
133
|
+
# @param aggregate [Yes::Core::Aggregate] Aggregate instance to use for recovery
|
|
134
|
+
# @param threshold [ActiveSupport::Duration] Time threshold for considering state stuck (default: 2 seconds)
|
|
135
|
+
# @return [Boolean] True if recovery succeeded or not needed, false if recovery failed
|
|
136
|
+
def attempt_inline_recovery(read_model, aggregate:, threshold: 2.seconds)
|
|
137
|
+
return true unless read_model.respond_to?(:pending_update_since)
|
|
138
|
+
|
|
139
|
+
attempt_recovery_if_pending(read_model, threshold, aggregate: aggregate)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
# Checks state and attempts recovery in one go
|
|
145
|
+
# @param read_model [ActiveRecord::Base] The read model to check
|
|
146
|
+
# @param threshold [ActiveSupport::Duration] Time threshold for recovery
|
|
147
|
+
# @param aggregate [Yes::Core::Aggregate] Aggregate instance to use for recovery
|
|
148
|
+
# @return [Boolean] True if no recovery needed or recovery succeeded, false if recovery needed but failed
|
|
149
|
+
def attempt_recovery_if_pending(read_model, threshold, aggregate:)
|
|
150
|
+
read_model.reload
|
|
151
|
+
|
|
152
|
+
# Not pending - we're good
|
|
153
|
+
return true if read_model.pending_update_since.blank?
|
|
154
|
+
|
|
155
|
+
# Pending but too recent - another process is working on it, proceed
|
|
156
|
+
return true unless read_model.pending_update_since < threshold.ago
|
|
157
|
+
|
|
158
|
+
# Pending and old enough - attempt recovery now
|
|
159
|
+
Rails.logger.info("Read model #{read_model.class.name}##{read_model.id} is in pending state, attempting recovery")
|
|
160
|
+
|
|
161
|
+
begin
|
|
162
|
+
updater = ReadModelUpdater.new(aggregate)
|
|
163
|
+
updater.call(
|
|
164
|
+
aggregate.latest_event,
|
|
165
|
+
aggregate.latest_event.data
|
|
166
|
+
)
|
|
167
|
+
Rails.logger.info("Successfully recovered read model #{read_model.class.name}##{read_model.id}")
|
|
168
|
+
true
|
|
169
|
+
rescue Yes::Core::CommandHandling::ReadModelRevisionGuard::RevisionAlreadyAppliedError
|
|
170
|
+
# Another thread already recovered - this is a successful outcome
|
|
171
|
+
Rails.logger.info("Recovery already completed by another thread for #{read_model.class.name}##{read_model.id}")
|
|
172
|
+
|
|
173
|
+
# Ensure pending flag is cleared if still set
|
|
174
|
+
read_model.reload
|
|
175
|
+
if read_model.pending_update_since.present?
|
|
176
|
+
read_model.update_column(:pending_update_since, nil)
|
|
177
|
+
Rails.logger.debug { "Cleared lingering pending_update_since flag for #{read_model.class.name}##{read_model.id}" }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
true
|
|
181
|
+
rescue ActiveRecord::ActiveRecordError, PgEventstore::Error => e
|
|
182
|
+
# Expected errors during recovery
|
|
183
|
+
Rails.logger.debug { "Recovery attempt failed for #{read_model.class.name}##{read_model.id}: #{e.message}" }
|
|
184
|
+
false
|
|
185
|
+
rescue StandardError => e
|
|
186
|
+
# Unexpected errors
|
|
187
|
+
Rails.logger.error("Unexpected error during recovery attempt for #{read_model.class.name}##{read_model.id}: #{e.class.name} - #{e.message}")
|
|
188
|
+
false
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Finds all read models stuck in pending state with their aggregate classes
|
|
193
|
+
# @param stuck_timeout [ActiveSupport::Duration] Time after which a model is considered stuck
|
|
194
|
+
# @param batch_size [Integer] Number of records to fetch
|
|
195
|
+
# @return [Array<Hash>] Stuck read models with their aggregate classes and draft flags
|
|
196
|
+
def find_stuck_read_models_with_aggregates(stuck_timeout:, batch_size:)
|
|
197
|
+
stuck_models = []
|
|
198
|
+
|
|
199
|
+
Yes::Core.configuration.all_read_models_with_aggregate_classes.each do |mapping|
|
|
200
|
+
read_model_class = mapping[:read_model_class]
|
|
201
|
+
aggregate_class = mapping[:aggregate_class]
|
|
202
|
+
is_draft = mapping[:is_draft]
|
|
203
|
+
|
|
204
|
+
next unless read_model_class.column_names.include?('pending_update_since')
|
|
205
|
+
|
|
206
|
+
read_model_class.
|
|
207
|
+
where.not(pending_update_since: nil).
|
|
208
|
+
where(pending_update_since: ...stuck_timeout.ago).
|
|
209
|
+
limit(batch_size).
|
|
210
|
+
each do |read_model|
|
|
211
|
+
stuck_models << { read_model:, aggregate_class:, is_draft: }
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
stuck_models
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Determines the aggregate ID from a read model
|
|
219
|
+
# @param read_model [ActiveRecord::Base] The read model
|
|
220
|
+
# @return [String] The aggregate ID
|
|
221
|
+
def determine_aggregate_id(read_model)
|
|
222
|
+
read_model.respond_to?(:aggregate_id) ? read_model.aggregate_id : read_model.id
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Acquires an advisory lock for the given key
|
|
226
|
+
# @param lock_key [String] The lock key
|
|
227
|
+
# @yield Block to execute with the lock
|
|
228
|
+
def with_advisory_lock(lock_key)
|
|
229
|
+
# Use PostgreSQL advisory lock
|
|
230
|
+
lock_id = Zlib.crc32(lock_key)
|
|
231
|
+
|
|
232
|
+
connection = ActiveRecord::Base.connection
|
|
233
|
+
obtained = connection.execute("SELECT pg_try_advisory_lock(#{lock_id})").first['pg_try_advisory_lock']
|
|
234
|
+
|
|
235
|
+
return yield if obtained
|
|
236
|
+
|
|
237
|
+
raise "Could not obtain advisory lock for #{lock_key}"
|
|
238
|
+
ensure
|
|
239
|
+
connection.execute("SELECT pg_advisory_unlock(#{lock_id})") if obtained
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Logs the result of a recovery attempt
|
|
243
|
+
# @param result [RecoveryResult] The recovery result
|
|
244
|
+
def log_recovery_result(result)
|
|
245
|
+
if result.success
|
|
246
|
+
Rails.logger.info(
|
|
247
|
+
"Successfully recovered read model: #{result.read_model.class.name}##{result.read_model.id}"
|
|
248
|
+
)
|
|
249
|
+
else
|
|
250
|
+
Rails.logger.error(
|
|
251
|
+
"Failed to recover read model: #{result.read_model.class.name}##{result.read_model.id} - #{result.error_message}"
|
|
252
|
+
)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Access to command utilities
|
|
257
|
+
def command_utilities
|
|
258
|
+
Yes::Core::CommandUtilities
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../utils/exponential_retrier'
|
|
4
|
+
|
|
5
|
+
module Yes
|
|
6
|
+
module Core
|
|
7
|
+
module CommandHandling
|
|
8
|
+
# Ensures that read model revisions match expected event revisions
|
|
9
|
+
# Uses ExponentialRetrier for retry logic with exponential backoff
|
|
10
|
+
# This handles eventual consistency between the event store and read model databases
|
|
11
|
+
class ReadModelRevisionGuard
|
|
12
|
+
# Error raised when revision guard fails after maximum retries
|
|
13
|
+
class RevisionMismatchError < Yes::Core::Error; end
|
|
14
|
+
|
|
15
|
+
# Error raised when timeout is exceeded
|
|
16
|
+
class TimeoutError < Yes::Core::Utils::ExponentialRetrier::TimeoutError; end
|
|
17
|
+
|
|
18
|
+
# Error raised when revision has already been applied
|
|
19
|
+
class RevisionAlreadyAppliedError < Yes::Core::Error; end
|
|
20
|
+
|
|
21
|
+
# Logger wrapper that adds revision context to log messages
|
|
22
|
+
class ContextualLogger
|
|
23
|
+
def initialize(base_logger, context)
|
|
24
|
+
@base_logger = base_logger
|
|
25
|
+
@context = context
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def info(message)
|
|
29
|
+
return unless base_logger
|
|
30
|
+
|
|
31
|
+
base_logger.info("#{message} for revision #{context.expected_revision}")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def error(message)
|
|
35
|
+
return unless base_logger
|
|
36
|
+
|
|
37
|
+
base_logger.error(
|
|
38
|
+
"#{message} for revision #{context.expected_revision}. " \
|
|
39
|
+
"Current revision: #{context.current_revision}"
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def debug(message)
|
|
44
|
+
return unless base_logger&.debug?
|
|
45
|
+
|
|
46
|
+
base_logger.debug(
|
|
47
|
+
"#{message} for revision #{context.expected_revision} (current: #{context.current_revision})"
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def debug?
|
|
52
|
+
base_logger&.debug?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
attr_reader :base_logger, :context
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class << self
|
|
61
|
+
# Calls the guard with a read model and expected revision
|
|
62
|
+
#
|
|
63
|
+
# @param read_model [Object] The read model to guard
|
|
64
|
+
# @param expected_revision [Integer] The expected revision (should be read_model.revision + 1)
|
|
65
|
+
# @param revision_column [Symbol] The revision column to use (defaults to :revision)
|
|
66
|
+
# @yield Block to execute when revisions match
|
|
67
|
+
# @return [Object] Result of the block execution
|
|
68
|
+
# @raise [RevisionMismatchError] When revisions don't match after maximum retries
|
|
69
|
+
# @raise [TimeoutError] When timeout is exceeded
|
|
70
|
+
# @raise [RevisionAlreadyAppliedError] When revision has already been applied
|
|
71
|
+
def call(read_model, expected_revision, revision_column: :revision, &)
|
|
72
|
+
new(read_model, expected_revision, revision_column:).call(&)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @param read_model [Object] The read model to guard
|
|
77
|
+
# @param expected_revision [Integer] The expected revision
|
|
78
|
+
# @param revision_column [Symbol] The revision column to use (defaults to :revision)
|
|
79
|
+
def initialize(read_model, expected_revision, revision_column: :revision)
|
|
80
|
+
@read_model = read_model
|
|
81
|
+
@expected_revision = expected_revision
|
|
82
|
+
@revision_column = revision_column
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Gets the expected revision for this guard
|
|
86
|
+
#
|
|
87
|
+
# @return [Integer] The expected revision value
|
|
88
|
+
attr_reader :expected_revision
|
|
89
|
+
|
|
90
|
+
# Gets the current revision from the read model using the specified column
|
|
91
|
+
#
|
|
92
|
+
# @return [Integer] The current revision value
|
|
93
|
+
def current_revision
|
|
94
|
+
read_model.public_send(revision_column)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Executes the guard logic with retry mechanism
|
|
98
|
+
#
|
|
99
|
+
# @yield Block to execute when revisions match
|
|
100
|
+
# @return [Object] Result of the block execution
|
|
101
|
+
# @raise [RevisionMismatchError] When revisions don't match after maximum retries
|
|
102
|
+
# @raise [TimeoutError] When timeout is exceeded
|
|
103
|
+
# @raise [RevisionAlreadyAppliedError] When revision has already been applied
|
|
104
|
+
def call(&)
|
|
105
|
+
retrier = create_retrier
|
|
106
|
+
|
|
107
|
+
begin
|
|
108
|
+
retrier.call(
|
|
109
|
+
condition_check: -> { check_revision_and_return_match_status },
|
|
110
|
+
failure_message: revision_mismatch_message,
|
|
111
|
+
timeout_message: timeout_message,
|
|
112
|
+
&
|
|
113
|
+
)
|
|
114
|
+
rescue Yes::Core::Utils::ExponentialRetrier::RetryFailedError => e
|
|
115
|
+
raise RevisionMismatchError, e.message
|
|
116
|
+
rescue Yes::Core::Utils::ExponentialRetrier::TimeoutError => e
|
|
117
|
+
raise TimeoutError, e.message
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
attr_reader :read_model, :revision_column
|
|
124
|
+
|
|
125
|
+
# Creates the retrier with custom logging context
|
|
126
|
+
#
|
|
127
|
+
# @return [Yes::Core::Utils::ExponentialRetrier] Configured retrier instance
|
|
128
|
+
def create_retrier
|
|
129
|
+
Yes::Core::Utils::ExponentialRetrier.new(
|
|
130
|
+
max_retries: 10,
|
|
131
|
+
base_sleep_time: 0.1,
|
|
132
|
+
max_sleep_time: 5.0,
|
|
133
|
+
jitter_factor: 0.1,
|
|
134
|
+
timeout: 30,
|
|
135
|
+
logger: create_contextual_logger
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Creates a logger wrapper that adds revision context to log messages
|
|
140
|
+
#
|
|
141
|
+
# @return [ContextualLogger] Logger with revision context
|
|
142
|
+
def create_contextual_logger
|
|
143
|
+
base_logger = defined?(Rails.logger) ? Rails.logger : nil
|
|
144
|
+
ContextualLogger.new(base_logger, self)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Checks revision status and returns whether revision matches
|
|
148
|
+
# Also handles the revision already applied error
|
|
149
|
+
#
|
|
150
|
+
# @return [Boolean] True if revision matches, false if record not found (retry)
|
|
151
|
+
# @raise [RevisionAlreadyAppliedError] When revision has already been applied
|
|
152
|
+
def check_revision_and_return_match_status
|
|
153
|
+
read_model.reload
|
|
154
|
+
check_revision_status!
|
|
155
|
+
revision_matches?
|
|
156
|
+
rescue ActiveRecord::RecordNotFound
|
|
157
|
+
# Record was just created but not yet visible due to transaction timing,
|
|
158
|
+
# read replica lag, or isolation levels. Return false to trigger retry.
|
|
159
|
+
false
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Checks if the current read model revision + 1 equals the expected revision
|
|
163
|
+
#
|
|
164
|
+
# @return [Boolean] True if revisions match
|
|
165
|
+
def revision_matches?
|
|
166
|
+
read_model.public_send(revision_column) + 1 == expected_revision
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Checks if we've somehow skipped past the expected revision
|
|
170
|
+
#
|
|
171
|
+
# @raise [RevisionAlreadyAppliedError] When revision has already been applied
|
|
172
|
+
def check_revision_status!
|
|
173
|
+
return unless current_revision >= expected_revision
|
|
174
|
+
|
|
175
|
+
raise RevisionAlreadyAppliedError,
|
|
176
|
+
"Expected revision #{expected_revision} but read model already at revision #{current_revision}. " \
|
|
177
|
+
'This revision may have already been applied.'
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Generates error message for revision mismatch
|
|
181
|
+
#
|
|
182
|
+
# @return [String] Error message
|
|
183
|
+
def revision_mismatch_message
|
|
184
|
+
'Revision mismatch. ' \
|
|
185
|
+
"Expected revision #{expected_revision}, but read model has revision #{current_revision}. " \
|
|
186
|
+
"Expected: read_model.#{revision_column} (#{current_revision}) + 1 = #{expected_revision}"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Generates error message for timeout
|
|
190
|
+
#
|
|
191
|
+
# @return [String] Timeout message
|
|
192
|
+
def timeout_message
|
|
193
|
+
"Timeout waiting for revision #{expected_revision}. Current revision: #{current_revision}"
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module CommandHandling
|
|
6
|
+
# Updates read models with revision guard to ensure consistency
|
|
7
|
+
# Handles state updater instantiation and execution within a revision-protected context
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# updater = ReadModelUpdater.new(aggregate)
|
|
11
|
+
# updater.call(event, command_payload, :approve_documents)
|
|
12
|
+
#
|
|
13
|
+
class ReadModelUpdater
|
|
14
|
+
include Yes::Core::OpenTelemetry::Trackable
|
|
15
|
+
|
|
16
|
+
# Initializes a new ReadModelUpdater
|
|
17
|
+
#
|
|
18
|
+
# @param aggregate [Yes::Core::Aggregate] The aggregate instance to update read model for
|
|
19
|
+
def initialize(aggregate)
|
|
20
|
+
@aggregate = aggregate
|
|
21
|
+
@read_model = aggregate.read_model if aggregate.class.read_model_enabled?
|
|
22
|
+
@command_utilities = aggregate.send(:command_utilities)
|
|
23
|
+
@revision_column = aggregate.send(:revision_column) if aggregate.class.read_model_enabled?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Updates the read model with revision guard protection
|
|
27
|
+
#
|
|
28
|
+
# @param event [Yes::Core::Event] The event that was published
|
|
29
|
+
# @param command_payload [Hash] The command payload
|
|
30
|
+
# @param command_name [Symbol, String] The command name (optional, will be derived from event if not provided)
|
|
31
|
+
# @return [void]
|
|
32
|
+
def call(event, command_payload = nil, command_name = nil, resolve_payload: false)
|
|
33
|
+
return unless aggregate.class.read_model_enabled?
|
|
34
|
+
|
|
35
|
+
begin
|
|
36
|
+
command_name ||= command_utilities.command_name_from_event(event, aggregate.class)
|
|
37
|
+
rescue Yes::Core::Utils::CommandUtils::CommandNotFoundError => e
|
|
38
|
+
Rails.logger.warn("Command not found for event #{event.type}: #{e.message}")
|
|
39
|
+
|
|
40
|
+
# update revision only in case event is unknown to aggregate
|
|
41
|
+
return update_revision(event.stream_revision)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
payload = command_payload || payload_from_event(event, resolve_payload)
|
|
45
|
+
|
|
46
|
+
locale = payload[:locale]
|
|
47
|
+
|
|
48
|
+
state_updater_class = command_utilities.fetch_state_updater_class(command_name)
|
|
49
|
+
|
|
50
|
+
ReadModelRevisionGuard.call(
|
|
51
|
+
read_model,
|
|
52
|
+
event.stream_revision,
|
|
53
|
+
revision_column:
|
|
54
|
+
) do
|
|
55
|
+
state_changes = state_updater_class.new(
|
|
56
|
+
payload: payload.except(*Yes::Core::Command::RESERVED_KEYS),
|
|
57
|
+
aggregate:,
|
|
58
|
+
event:
|
|
59
|
+
).call
|
|
60
|
+
aggregate.update_read_model(
|
|
61
|
+
state_changes.merge(
|
|
62
|
+
revision_column => event.stream_revision,
|
|
63
|
+
locale:,
|
|
64
|
+
pending_update_since: nil
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
rescue ReadModelRevisionGuard::RevisionAlreadyAppliedError => e
|
|
69
|
+
Rails.logger.warn("Read model revision already applied: #{e.message}")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
otl_trackable(
|
|
73
|
+
:call,
|
|
74
|
+
Yes::Core::OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Update read model',
|
|
75
|
+
span_kind: :producer, track_sql: true)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
attr_reader :aggregate, :read_model, :command_utilities, :revision_column
|
|
81
|
+
|
|
82
|
+
def update_revision(revision)
|
|
83
|
+
aggregate.update_read_model(revision_column => revision)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def payload_from_event(event, resolve_payload)
|
|
87
|
+
return event.data unless resolve_payload
|
|
88
|
+
return event.data unless event.data.values.any? do |value|
|
|
89
|
+
next false unless value.is_a?(String)
|
|
90
|
+
|
|
91
|
+
value.start_with?(Yes::Core::Event::PAYLOAD_STORE_VALUE_PREFIX)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
Yes::Core::PayloadStore::Lookup.new.call(event).each do |key, value|
|
|
95
|
+
event.data[key.to_s] = value
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
event.data
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module CommandHandling
|
|
6
|
+
# Base class for handling custom state updates on command attributes
|
|
7
|
+
class StateUpdater
|
|
8
|
+
class << self
|
|
9
|
+
# @return [Hash] The update state block
|
|
10
|
+
attr_reader :update_state_block
|
|
11
|
+
attr_reader :updated_attributes
|
|
12
|
+
|
|
13
|
+
# Defines the update state block and analyzes it for attribute updates
|
|
14
|
+
#
|
|
15
|
+
# @param custom [Boolean] Skip attribute analysis when true
|
|
16
|
+
# @yield Block to evaluate for state updates
|
|
17
|
+
# @yieldreturn [void]
|
|
18
|
+
# @return [void]
|
|
19
|
+
def update_state(custom: false, &block)
|
|
20
|
+
@update_state_block = block
|
|
21
|
+
@updated_attributes = []
|
|
22
|
+
|
|
23
|
+
# Only analyze the block if not in custom mode
|
|
24
|
+
return if custom
|
|
25
|
+
|
|
26
|
+
analyzer = BlockAnalyzer.new
|
|
27
|
+
analyzer.instance_eval(&block)
|
|
28
|
+
@updated_attributes = analyzer.updated_attributes
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Helper class to analyze the update_state block at definition time
|
|
33
|
+
class BlockAnalyzer
|
|
34
|
+
attr_reader :updated_attributes
|
|
35
|
+
|
|
36
|
+
def initialize
|
|
37
|
+
@updated_attributes = []
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param method_name [Symbol] The method being called
|
|
41
|
+
# @param args [Array] Method arguments (unused)
|
|
42
|
+
# @yield Optional block to evaluate for attribute value
|
|
43
|
+
# @yieldreturn [void]
|
|
44
|
+
# @return [void]
|
|
45
|
+
def method_missing(method_name, *_args, &block)
|
|
46
|
+
@updated_attributes << method_name if block
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def respond_to_missing?(*, **)
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @param payload [Hash] The command payload
|
|
55
|
+
# @param aggregate [Yes::Core::Aggregate] The aggregate instance
|
|
56
|
+
# @param event [Event, nil] The event instance (optional)
|
|
57
|
+
def initialize(payload:, aggregate:, event: nil)
|
|
58
|
+
@raw_payload = payload
|
|
59
|
+
@aggregate = aggregate
|
|
60
|
+
@event = event
|
|
61
|
+
@payload = PayloadProxy.new(
|
|
62
|
+
raw_payload:,
|
|
63
|
+
context: aggregate.class.context,
|
|
64
|
+
parent_aggregates: aggregate.class.parent_aggregates
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Evaluates the update state block and returns the updated attributes
|
|
69
|
+
# If no block is defined, returns the payload attributes except aggregate_id
|
|
70
|
+
#
|
|
71
|
+
# @return [Hash] The updated attributes
|
|
72
|
+
def call
|
|
73
|
+
if self.class.update_state_block
|
|
74
|
+
@updates = {}
|
|
75
|
+
instance_eval(&self.class.update_state_block)
|
|
76
|
+
@updates
|
|
77
|
+
else
|
|
78
|
+
raw_payload.except(:"#{aggregate.class.aggregate.underscore}_id")
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
attr_reader :raw_payload, :payload, :aggregate, :event
|
|
85
|
+
|
|
86
|
+
# Handles method missing to delegate attribute calls to the current aggregate
|
|
87
|
+
#
|
|
88
|
+
# @param method_name [Symbol] The method name being called
|
|
89
|
+
# @yield Optional block to evaluate for the attribute value
|
|
90
|
+
# @yieldreturn [Object] The value to set for the attribute
|
|
91
|
+
# @return [Object] The result of calling the method on the current aggregate
|
|
92
|
+
def method_missing(method_name, *, &block)
|
|
93
|
+
if block
|
|
94
|
+
@updates[method_name] = instance_eval(&block)
|
|
95
|
+
elsif aggregate.respond_to?(method_name)
|
|
96
|
+
aggregate.public_send(method_name, *, &block)
|
|
97
|
+
else
|
|
98
|
+
super
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Checks if method is defined on the current aggregate
|
|
103
|
+
#
|
|
104
|
+
# @param method_name [Symbol] The method name to check
|
|
105
|
+
# @param include_private [Boolean] Whether to include private methods
|
|
106
|
+
# @return [Boolean] True if method exists
|
|
107
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
108
|
+
aggregate.respond_to?(method_name, include_private) || super
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|