yes-core 2.2.0 → 2.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4845d0466658aee907edb5bab0679603e25a9b5c088a8df067d0fbfcc060bff4
4
- data.tar.gz: cca505e973fa99d5b8e4b07c44752b78b330fdc0035b45528c83139ee36a0e4b
3
+ metadata.gz: '06386ad1e1b30ddacbb4afaf392632875023650518041e3914178427039f91f5'
4
+ data.tar.gz: af40c3f3d98c611ce0653dfb0c0e2e2193376feb1ed0aeba69d43827fd6a09f9
5
5
  SHA512:
6
- metadata.gz: '08121024f7bb36c1bb17281d772c907f4c1e9c119437caf6642c0461fcc9ce2d434c15fa6dc548ec8c6362b520b5144f8869d7054c8e9a110e33a984d757fd63'
7
- data.tar.gz: b2b2bcbf061918d7876e27c6014d41adfe1ed5133163a078d09a31490219105a57f47699c5bd2d6c9d3400c12df527c13e2d0f76d64b32f1d4a2b1aaec1b4b8f
6
+ metadata.gz: e956ac8483b7a53f13009356536c221f92dc2dd6d88f6137ca4d1e6305a7c239a2870484197469243c14dca6552983d0004b263147ad6642d94c17611415cfd6
7
+ data.tar.gz: 5ac3c8ea889ddf9a01efc1ac645a35c9d394736f351e1f98d9ca1dc0f5550094ded103ba424f2a8df07f24d46a082948a69e719aab3aa330d18736d0815de17a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.3.0] - 2026-09-03
4
+ - See root CHANGELOG.md for details.
5
+
3
6
  ## [2.2.0] - 2026-09-01
4
7
 
5
8
  - See root CHANGELOG.md for details.
@@ -55,6 +55,8 @@ module Yes
55
55
  # response = executor.call(command, guard_evaluator_class)
56
56
  #
57
57
  class CommandExecutor
58
+ include RevisionConflictWaiting
59
+
58
60
  MAX_RETRIES = 10
59
61
  INLINE_RECOVERY_RETRY_THRESHOLD = 5
60
62
 
@@ -96,13 +98,15 @@ module Yes
96
98
  rescue PgEventstore::WrongExpectedRevisionError => e
97
99
  retries += 1
98
100
  clear_pending_update_state if aggregate.class.read_model_enabled?
101
+ raise e if retries > MAX_RETRIES
99
102
 
100
- retries <= MAX_RETRIES ? retry : raise(e)
103
+ wait_for_read_model(e, retries)
104
+ retry
101
105
  rescue ConcurrentUpdateError => e
102
106
  retries += 1
103
107
  # Don't clear pending state - another process owns it
104
108
  # Sleep with exponential backoff to give the other process time to finish
105
- sleep([0.01 * (2**(retries - 1)), 1.0].min) if retries <= MAX_RETRIES
109
+ sleep(RevisionConflictBackoff.schedule(retries)) if retries <= MAX_RETRIES
106
110
 
107
111
  # After several retries, check if pending state is stuck and attempt recovery
108
112
  # This prevents infinite retry loops when a process crashes leaving the flag set
@@ -17,6 +17,8 @@ module Yes
17
17
  # Only the group's own guards run here — sub-command guards are
18
18
  # bypassed by design.
19
19
  class CommandGroupExecutor
20
+ include RevisionConflictWaiting
21
+
20
22
  MAX_RETRIES = 10
21
23
  INLINE_RECOVERY_RETRY_THRESHOLD = 5
22
24
 
@@ -47,10 +49,13 @@ module Yes
47
49
  rescue PgEventstore::WrongExpectedRevisionError => e
48
50
  retries += 1
49
51
  clear_pending_update_state if aggregate.class.read_model_enabled?
50
- retries <= MAX_RETRIES ? retry : raise(e)
52
+ raise e if retries > MAX_RETRIES
53
+
54
+ wait_for_read_model(e, retries)
55
+ retry
51
56
  rescue ConcurrentUpdateError => e
52
57
  retries += 1
53
- sleep([0.01 * (2**(retries - 1)), 1.0].min) if retries <= MAX_RETRIES
58
+ sleep(RevisionConflictBackoff.schedule(retries)) if retries <= MAX_RETRIES
54
59
 
55
60
  if aggregate.class.read_model_enabled? && retries >= INLINE_RECOVERY_RETRY_THRESHOLD
56
61
  ReadModelRecoveryService.attempt_inline_recovery(read_model, aggregate: aggregate)
@@ -106,17 +106,17 @@ module Yes
106
106
  name: aggregate_data[:name],
107
107
  id: aggregate_data[:id]
108
108
  )
109
- expected_revision = command_utilities.stream_revision(stream)
109
+ stream_revision = command_utilities.stream_revision(stream)
110
110
  aggregate_revision = aggregate_data[:revision].call
111
- normalized_revision = aggregate_revision == -1 ? :no_stream : aggregate_revision
111
+ expected_revision = aggregate_revision == -1 ? :no_stream : aggregate_revision
112
+ next if expected_revision == stream_revision
112
113
 
113
- next if normalized_revision == expected_revision
114
-
115
- # pg_eventstore 3.0 requires `verdict:`, which selects the error's
116
- # message. This branch is only reached when the revision we hold
117
- # differs from the store's, which is exactly :unmatched_stream_revision.
114
+ # Same argument convention as pg_eventstore itself: `revision` is what the
115
+ # store holds, `expected_revision` what we held. pg_eventstore 3.0 requires
116
+ # `verdict:`, which selects the error's message; this branch is only reached
117
+ # when the two differ, which is exactly :unmatched_stream_revision.
118
118
  raise PgEventstore::WrongExpectedRevisionError.new(
119
- revision: aggregate_revision,
119
+ revision: stream_revision,
120
120
  expected_revision:,
121
121
  stream:,
122
122
  verdict: :unmatched_stream_revision
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module CommandHandling
6
+ # Decides how long an executor waits before retrying a command after a
7
+ # PgEventstore::WrongExpectedRevisionError.
8
+ #
9
+ # The executors derive the expected revision from the read model's revision
10
+ # column. When the conflicting append came from this service, the read model
11
+ # was updated in the same process, so the very next attempt already sees the
12
+ # new revision and any wait only adds latency. When another service appended
13
+ # to the stream, the column only advances once this service's event listener
14
+ # has projected that event, typically a few hundred milliseconds later.
15
+ # Retrying immediately then burns every attempt inside the same half second
16
+ # and the command surfaces as a 500.
17
+ #
18
+ # The error carries the stream's actual revision, so for the aggregate's own
19
+ # stream the two cases can be told apart: retry at once while the reloaded
20
+ # read model has caught up, back off while it is still behind. A conflict on
21
+ # an external aggregate's stream (see EventPublisher#verify_external_revisions!)
22
+ # cannot be checked here and always backs off.
23
+ #
24
+ # Delays follow the same 10 ms doubling schedule as the ConcurrentUpdateError
25
+ # branch, capped per attempt and in total, with jitter so that requests which
26
+ # collided once do not retry in lockstep.
27
+ class RevisionConflictBackoff
28
+ # @return [Float] delay of the first waiting attempt, in seconds
29
+ BASE_DELAY_SECONDS = 0.01
30
+ # @return [Float] longest delay of a single attempt, in seconds
31
+ MAX_DELAY_SECONDS = 1.0
32
+ # @return [Float] total sleep budget across all retries of one command, in seconds
33
+ TOTAL_BUDGET_SECONDS = 2.0
34
+ # @return [Float] fraction by which a delay is randomised in both directions
35
+ JITTER_FRACTION = 0.25
36
+
37
+ class << self
38
+ # @param error [PgEventstore::WrongExpectedRevisionError] the conflict that was raised
39
+ # @param aggregate_id [String] id of the aggregate the executor works on; used to tell its own
40
+ # stream from an external aggregate's stream
41
+ # @param read_model_revision [Integer, nil] the revision the read model reports after a
42
+ # reload, or nil when the aggregate has no read model
43
+ # @param attempt [Integer] the 1-based retry attempt about to be made
44
+ # @return [Float] seconds to wait before retrying, 0.0 to retry immediately
45
+ def delay(error:, aggregate_id:, read_model_revision:, attempt:)
46
+ return 0.0 unless worth_waiting?(error, aggregate_id, read_model_revision)
47
+
48
+ remaining = TOTAL_BUDGET_SECONDS - waited_before(attempt)
49
+ return 0.0 unless remaining.positive?
50
+
51
+ jittered([schedule(attempt), remaining].min)
52
+ end
53
+
54
+ # The undisturbed exponential schedule, shared with the ConcurrentUpdateError retries.
55
+ #
56
+ # @param attempt [Integer] the 1-based retry attempt
57
+ # @return [Float] seconds
58
+ def schedule(attempt)
59
+ [BASE_DELAY_SECONDS * (2**(attempt - 1)), MAX_DELAY_SECONDS].min
60
+ end
61
+
62
+ private
63
+
64
+ # @return [Boolean] false only when the conflict is on the aggregate's own stream and the
65
+ # read model already reports at least the stream revision the conflict was raised with
66
+ def worth_waiting?(error, aggregate_id, read_model_revision)
67
+ return true unless own_stream?(error.stream, aggregate_id)
68
+ return false unless read_model_revision.is_a?(Integer) && error.revision.is_a?(Integer)
69
+
70
+ read_model_revision < error.revision
71
+ end
72
+
73
+ # @return [Boolean] true when the stream belongs to the aggregate itself; a stream that
74
+ # cannot be inspected is treated as the aggregate's own
75
+ def own_stream?(stream, aggregate_id)
76
+ return true unless stream.respond_to?(:stream_id)
77
+
78
+ stream.stream_id.to_s == aggregate_id.to_s
79
+ end
80
+
81
+ # @return [Float] seconds already spent sleeping before this attempt
82
+ def waited_before(attempt)
83
+ (1...attempt).sum { schedule(_1) }
84
+ end
85
+
86
+ # @return [Float] the delay randomised by ±JITTER_FRACTION
87
+ def jittered(delay)
88
+ delay * (1 + (JITTER_FRACTION * ((2 * rand) - 1)))
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module CommandHandling
6
+ # Shared by {CommandExecutor} and {CommandGroupExecutor}: the wait between two
7
+ # attempts after a PgEventstore::WrongExpectedRevisionError. Expects the
8
+ # including class to expose a private +aggregate+ reader.
9
+ module RevisionConflictWaiting
10
+ private
11
+
12
+ # Sleeps only while the read model has not yet caught up with the stream
13
+ # revision reported by the conflict, see {RevisionConflictBackoff}.
14
+ #
15
+ # @param error [PgEventstore::WrongExpectedRevisionError]
16
+ # @param attempt [Integer] the 1-based retry attempt about to be made
17
+ # @return [void]
18
+ def wait_for_read_model(error, attempt)
19
+ delay = RevisionConflictBackoff.delay(
20
+ error:, aggregate_id: aggregate.id, read_model_revision: current_read_model_revision, attempt:
21
+ )
22
+ sleep(delay) if delay.positive?
23
+ end
24
+
25
+ # @return [Integer, nil] the freshly reloaded read model revision; nil without a read model or
26
+ # when the row disappeared underneath the retry, both of which mean "retry immediately"
27
+ def current_read_model_revision
28
+ return nil unless aggregate.class.read_model_enabled?
29
+
30
+ aggregate.reload.revision
31
+ rescue ActiveRecord::RecordNotFound
32
+ nil
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -191,12 +191,15 @@ module Yes
191
191
  expected = expected_revision(stream)
192
192
  next if revision == expected
193
193
 
194
- revision_error!(revision || -1, expected || -1, stream)
194
+ revision_error!(expected || -1, revision || -1, stream)
195
195
  end
196
196
  end
197
197
 
198
- # @param revision [Integer]
199
- # @param expected_revision [Integer]
198
+ # Same argument convention as pg_eventstore itself: `revision` is what the store
199
+ # holds, `expected_revision` what this handler held.
200
+ #
201
+ # @param revision [Integer] the stream's current revision
202
+ # @param expected_revision [Integer] the revision the handler expected
200
203
  # @param stream [PgEventstore::Stream]
201
204
  def revision_error!(revision, expected_revision, stream)
202
205
  # pg_eventstore 3.0 requires `verdict:`, which selects the error's
@@ -209,7 +212,7 @@ module Yes
209
212
  self.class.current_span&.add_attributes(
210
213
  {
211
214
  current_revision: revision,
212
- expected_revision: expected_revision,
215
+ expected_revision:,
213
216
  stream: stream.to_json
214
217
  }.stringify_keys
215
218
  )
@@ -2,9 +2,28 @@
2
2
 
3
3
  module Yes
4
4
  module Core
5
+ # Base class for the gem's errors, carrying optional caller-supplied context.
6
+ #
7
+ # @example Raising with context
8
+ # raise Yes::Core::Error.new('could not resolve the aggregate', extra: { id: })
9
+ #
10
+ # @example Consuming the context safely
11
+ # # #extra is not guaranteed to be a Hash - type-check before merging it.
12
+ # payload.merge!(error.extra) if error.extra.is_a?(Hash)
5
13
  class Error < StandardError
14
+ # Caller-supplied context, returned exactly as it was given.
15
+ #
16
+ # It defaults to +nil+ and is never coerced or validated, so it may be any object the
17
+ # caller passed. Code that treats it as a Hash must therefore type-check first: a bare
18
+ # +payload.merge!(error.extra)+ raises +TypeError+ both for the +nil+ default and for any
19
+ # other non-Hash value. That matters most inside error-reporting hooks, where such a
20
+ # +TypeError+ tends to be swallowed by the reporter and takes the report down with it.
21
+ #
22
+ # @return [Object, nil] whatever the caller supplied; +nil+ when nothing was
6
23
  attr_reader :extra
7
24
 
25
+ # @param message [String, nil] the error message
26
+ # @param extra [Object, nil] arbitrary context to attach; stored as given
8
27
  def initialize(message = nil, extra: nil)
9
28
  super(message)
10
29
  @extra = extra
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Yes
4
4
  module Core
5
- VERSION = '2.2.0'
5
+ VERSION = '2.3.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yes-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.2.0
4
+ version: 2.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nico Ritsche
@@ -242,6 +242,8 @@ files:
242
242
  - lib/yes/core/command_handling/read_model_recovery_service.rb
243
243
  - lib/yes/core/command_handling/read_model_revision_guard.rb
244
244
  - lib/yes/core/command_handling/read_model_updater.rb
245
+ - lib/yes/core/command_handling/revision_conflict_backoff.rb
246
+ - lib/yes/core/command_handling/revision_conflict_waiting.rb
245
247
  - lib/yes/core/command_handling/state_updater.rb
246
248
  - lib/yes/core/commands/bus.rb
247
249
  - lib/yes/core/commands/command_group.rb