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.
Files changed (128) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +69 -0
  5. data/lib/yes/core/active_job_serializers/command_group_serializer.rb +29 -0
  6. data/lib/yes/core/active_job_serializers/dry_struct_serializer.rb +57 -0
  7. data/lib/yes/core/aggregate/draftable.rb +205 -0
  8. data/lib/yes/core/aggregate/dsl/attribute_data.rb +37 -0
  9. data/lib/yes/core/aggregate/dsl/attribute_definer.rb +54 -0
  10. data/lib/yes/core/aggregate/dsl/attribute_definers/aggregate.rb +36 -0
  11. data/lib/yes/core/aggregate/dsl/attribute_definers/standard.rb +36 -0
  12. data/lib/yes/core/aggregate/dsl/class_name_convention.rb +80 -0
  13. data/lib/yes/core/aggregate/dsl/class_resolvers/authorizer.rb +132 -0
  14. data/lib/yes/core/aggregate/dsl/class_resolvers/base.rb +80 -0
  15. data/lib/yes/core/aggregate/dsl/class_resolvers/command/authorizer.rb +30 -0
  16. data/lib/yes/core/aggregate/dsl/class_resolvers/command/authorizer_factory.rb +34 -0
  17. data/lib/yes/core/aggregate/dsl/class_resolvers/command/base.rb +38 -0
  18. data/lib/yes/core/aggregate/dsl/class_resolvers/command/cerbos_authorizer.rb +114 -0
  19. data/lib/yes/core/aggregate/dsl/class_resolvers/command/command.rb +70 -0
  20. data/lib/yes/core/aggregate/dsl/class_resolvers/command/event.rb +88 -0
  21. data/lib/yes/core/aggregate/dsl/class_resolvers/command/guard_evaluator.rb +84 -0
  22. data/lib/yes/core/aggregate/dsl/class_resolvers/command/simple_authorizer.rb +50 -0
  23. data/lib/yes/core/aggregate/dsl/class_resolvers/command/state_updater.rb +46 -0
  24. data/lib/yes/core/aggregate/dsl/class_resolvers/read_model.rb +75 -0
  25. data/lib/yes/core/aggregate/dsl/class_resolvers/read_model_filter.rb +88 -0
  26. data/lib/yes/core/aggregate/dsl/class_resolvers/read_model_serializer.rb +76 -0
  27. data/lib/yes/core/aggregate/dsl/command_data.rb +54 -0
  28. data/lib/yes/core/aggregate/dsl/command_definer.rb +263 -0
  29. data/lib/yes/core/aggregate/dsl/command_shortcut_expander.rb +233 -0
  30. data/lib/yes/core/aggregate/dsl/constant_resolver.rb +67 -0
  31. data/lib/yes/core/aggregate/dsl/method_definers/attribute/accessor.rb +28 -0
  32. data/lib/yes/core/aggregate/dsl/method_definers/attribute/aggregate_accessor.rb +36 -0
  33. data/lib/yes/core/aggregate/dsl/method_definers/attribute/base.rb +42 -0
  34. data/lib/yes/core/aggregate/dsl/method_definers/command/base.rb +42 -0
  35. data/lib/yes/core/aggregate/dsl/method_definers/command/can_command.rb +41 -0
  36. data/lib/yes/core/aggregate/dsl/method_definers/command/command.rb +50 -0
  37. data/lib/yes/core/aggregate/has_authorizer.rb +86 -0
  38. data/lib/yes/core/aggregate/has_read_model.rb +169 -0
  39. data/lib/yes/core/aggregate/read_model_rebuilder.rb +40 -0
  40. data/lib/yes/core/aggregate/shared_read_model_rebuilder.rb +158 -0
  41. data/lib/yes/core/aggregate.rb +404 -0
  42. data/lib/yes/core/authentication_error.rb +8 -0
  43. data/lib/yes/core/authorization/cerbos_client_provider.rb +27 -0
  44. data/lib/yes/core/authorization/command_authorizer.rb +40 -0
  45. data/lib/yes/core/authorization/command_cerbos_authorizer.rb +182 -0
  46. data/lib/yes/core/authorization/read_model_authorizer.rb +22 -0
  47. data/lib/yes/core/authorization/read_models_authorizer.rb +49 -0
  48. data/lib/yes/core/authorization/read_request_authorizer.rb +32 -0
  49. data/lib/yes/core/authorization/read_request_cerbos_authorizer.rb +112 -0
  50. data/lib/yes/core/command.rb +35 -0
  51. data/lib/yes/core/command_handling/aggregate_tracker.rb +33 -0
  52. data/lib/yes/core/command_handling/command_executor.rb +171 -0
  53. data/lib/yes/core/command_handling/command_handler.rb +124 -0
  54. data/lib/yes/core/command_handling/event_publisher.rb +189 -0
  55. data/lib/yes/core/command_handling/guard_evaluator.rb +165 -0
  56. data/lib/yes/core/command_handling/guard_runner.rb +76 -0
  57. data/lib/yes/core/command_handling/payload_proxy.rb +159 -0
  58. data/lib/yes/core/command_handling/read_model_recovery_service.rb +264 -0
  59. data/lib/yes/core/command_handling/read_model_revision_guard.rb +198 -0
  60. data/lib/yes/core/command_handling/read_model_updater.rb +103 -0
  61. data/lib/yes/core/command_handling/state_updater.rb +113 -0
  62. data/lib/yes/core/commands/bus.rb +46 -0
  63. data/lib/yes/core/commands/group.rb +135 -0
  64. data/lib/yes/core/commands/group_response.rb +13 -0
  65. data/lib/yes/core/commands/helper.rb +126 -0
  66. data/lib/yes/core/commands/notifier.rb +65 -0
  67. data/lib/yes/core/commands/processor.rb +137 -0
  68. data/lib/yes/core/commands/response.rb +63 -0
  69. data/lib/yes/core/commands/stateless/group_handler.rb +186 -0
  70. data/lib/yes/core/commands/stateless/group_response.rb +15 -0
  71. data/lib/yes/core/commands/stateless/handler.rb +292 -0
  72. data/lib/yes/core/commands/stateless/handler_helpers.rb +321 -0
  73. data/lib/yes/core/commands/stateless/response.rb +14 -0
  74. data/lib/yes/core/commands/stateless/subject.rb +41 -0
  75. data/lib/yes/core/commands/validator.rb +28 -0
  76. data/lib/yes/core/configuration.rb +432 -0
  77. data/lib/yes/core/data_decryptor.rb +59 -0
  78. data/lib/yes/core/data_encryptor.rb +60 -0
  79. data/lib/yes/core/encryption_metadata.rb +33 -0
  80. data/lib/yes/core/error.rb +14 -0
  81. data/lib/yes/core/error_messages.rb +37 -0
  82. data/lib/yes/core/event.rb +222 -0
  83. data/lib/yes/core/event_class_resolver.rb +40 -0
  84. data/lib/yes/core/generators/read_models/add_pending_update_tracking_generator.rb +43 -0
  85. data/lib/yes/core/generators/read_models/templates/add_pending_update_tracking.rb.erb +122 -0
  86. data/lib/yes/core/generators/read_models/templates/migration.rb.erb +9 -0
  87. data/lib/yes/core/generators/read_models/update_generator.rb +147 -0
  88. data/lib/yes/core/jobs/read_model_recovery_job.rb +219 -0
  89. data/lib/yes/core/middlewares/encryptor.rb +48 -0
  90. data/lib/yes/core/middlewares/timestamp.rb +29 -0
  91. data/lib/yes/core/middlewares/with_indifferent_access.rb +22 -0
  92. data/lib/yes/core/models/application_record.rb +9 -0
  93. data/lib/yes/core/open_telemetry/otl_span.rb +110 -0
  94. data/lib/yes/core/open_telemetry/trackable.rb +101 -0
  95. data/lib/yes/core/payload_store/base.rb +33 -0
  96. data/lib/yes/core/payload_store/errors.rb +13 -0
  97. data/lib/yes/core/payload_store/lookup.rb +44 -0
  98. data/lib/yes/core/process_managers/access_token_client.rb +107 -0
  99. data/lib/yes/core/process_managers/base.rb +40 -0
  100. data/lib/yes/core/process_managers/command_runner.rb +109 -0
  101. data/lib/yes/core/process_managers/service_client.rb +57 -0
  102. data/lib/yes/core/process_managers/state.rb +118 -0
  103. data/lib/yes/core/railtie.rb +58 -0
  104. data/lib/yes/core/read_model/builder.rb +267 -0
  105. data/lib/yes/core/read_model/event_handler.rb +64 -0
  106. data/lib/yes/core/read_model/filter.rb +118 -0
  107. data/lib/yes/core/read_model/filter_query_builder.rb +104 -0
  108. data/lib/yes/core/serializer.rb +21 -0
  109. data/lib/yes/core/subscriptions.rb +94 -0
  110. data/lib/yes/core/test_support/event_helpers.rb +27 -0
  111. data/lib/yes/core/test_support/jwt_helpers.rb +30 -0
  112. data/lib/yes/core/test_support/subscriptions_helper.rb +88 -0
  113. data/lib/yes/core/test_support/test_helper.rb +27 -0
  114. data/lib/yes/core/test_support.rb +5 -0
  115. data/lib/yes/core/transaction_details.rb +90 -0
  116. data/lib/yes/core/type_lookup.rb +88 -0
  117. data/lib/yes/core/types.rb +110 -0
  118. data/lib/yes/core/utils/aggregate_shortcuts.rb +164 -0
  119. data/lib/yes/core/utils/caller_utils.rb +37 -0
  120. data/lib/yes/core/utils/command_utils.rb +226 -0
  121. data/lib/yes/core/utils/error_notifier.rb +101 -0
  122. data/lib/yes/core/utils/event_name_resolver.rb +67 -0
  123. data/lib/yes/core/utils/exponential_retrier.rb +180 -0
  124. data/lib/yes/core/utils/hash_utils.rb +63 -0
  125. data/lib/yes/core/version.rb +7 -0
  126. data/lib/yes/core.rb +85 -0
  127. data/lib/yes.rb +0 -0
  128. 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