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 +4 -4
- data/CHANGELOG.md +3 -0
- data/lib/yes/core/command_handling/command_executor.rb +6 -2
- data/lib/yes/core/command_handling/command_group_executor.rb +7 -2
- data/lib/yes/core/command_handling/event_publisher.rb +8 -8
- data/lib/yes/core/command_handling/revision_conflict_backoff.rb +94 -0
- data/lib/yes/core/command_handling/revision_conflict_waiting.rb +37 -0
- data/lib/yes/core/commands/stateless/handler.rb +7 -4
- data/lib/yes/core/error.rb +19 -0
- data/lib/yes/core/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '06386ad1e1b30ddacbb4afaf392632875023650518041e3914178427039f91f5'
|
|
4
|
+
data.tar.gz: af40c3f3d98c611ce0653dfb0c0e2e2193376feb1ed0aeba69d43827fd6a09f9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e956ac8483b7a53f13009356536c221f92dc2dd6d88f6137ca4d1e6305a7c239a2870484197469243c14dca6552983d0004b263147ad6642d94c17611415cfd6
|
|
7
|
+
data.tar.gz: 5ac3c8ea889ddf9a01efc1ac645a35c9d394736f351e1f98d9ca1dc0f5550094ded103ba424f2a8df07f24d46a082948a69e719aab3aa330d18736d0815de17a
|
data/CHANGELOG.md
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
109
|
+
stream_revision = command_utilities.stream_revision(stream)
|
|
110
110
|
aggregate_revision = aggregate_data[:revision].call
|
|
111
|
-
|
|
111
|
+
expected_revision = aggregate_revision == -1 ? :no_stream : aggregate_revision
|
|
112
|
+
next if expected_revision == stream_revision
|
|
112
113
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
#
|
|
116
|
-
#
|
|
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:
|
|
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!(
|
|
194
|
+
revision_error!(expected || -1, revision || -1, stream)
|
|
195
195
|
end
|
|
196
196
|
end
|
|
197
197
|
|
|
198
|
-
#
|
|
199
|
-
#
|
|
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
|
|
215
|
+
expected_revision:,
|
|
213
216
|
stream: stream.to_json
|
|
214
217
|
}.stringify_keys
|
|
215
218
|
)
|
data/lib/yes/core/error.rb
CHANGED
|
@@ -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
|
data/lib/yes/core/version.rb
CHANGED
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.
|
|
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
|