yes-core 1.3.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 +15 -0
- data/README.md +13 -0
- data/lib/yes/core/active_job_serializers/command_group_serializer.rb +7 -4
- data/lib/yes/core/authorization/command_cerbos_authorizer.rb +20 -5
- data/lib/yes/core/authorization/lookup_cache.rb +72 -0
- data/lib/yes/core/command_handling/command_executor.rb +6 -2
- data/lib/yes/core/command_handling/command_group_executor.rb +9 -3
- data/lib/yes/core/command_handling/event_publisher.rb +13 -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/command_group.rb +8 -3
- data/lib/yes/core/commands/processor.rb +28 -7
- data/lib/yes/core/commands/stateless/handler.rb +16 -7
- data/lib/yes/core/commands/stateless/handler_helpers.rb +1 -1
- data/lib/yes/core/configuration.rb +17 -0
- data/lib/yes/core/data_decryptor.rb +2 -2
- data/lib/yes/core/data_encryptor.rb +6 -2
- data/lib/yes/core/error.rb +19 -0
- data/lib/yes/core/failed_subscription_notifier.rb +29 -0
- data/lib/yes/core/middlewares/encryptor.rb +9 -1
- data/lib/yes/core/middlewares/write_encryptor.rb +35 -0
- data/lib/yes/core/middlewares.rb +45 -0
- data/lib/yes/core/railtie.rb +20 -0
- data/lib/yes/core/test_support/event_helpers.rb +1 -1
- data/lib/yes/core/types.rb +8 -1
- data/lib/yes/core/utils/hash_utils.rb +7 -2
- data/lib/yes/core/version.rb +1 -1
- metadata +8 -3
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
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.3.0] - 2026-09-03
|
|
4
|
+
- See root CHANGELOG.md for details.
|
|
5
|
+
|
|
6
|
+
## [2.2.0] - 2026-09-01
|
|
7
|
+
|
|
8
|
+
- See root CHANGELOG.md for details.
|
|
9
|
+
|
|
10
|
+
## [1.4.0] - 2026-06-24
|
|
11
|
+
|
|
12
|
+
- See root CHANGELOG.md for details.
|
|
13
|
+
|
|
14
|
+
## [1.3.1] - 2026-06-16
|
|
15
|
+
|
|
16
|
+
- See root CHANGELOG.md for details.
|
|
17
|
+
|
|
3
18
|
## [1.3.0] - 2026-05-18
|
|
4
19
|
|
|
5
20
|
- See root CHANGELOG.md for details.
|
data/README.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
Core event sourcing framework providing the aggregate DSL, commands, events, read models, and supporting infrastructure for the [Yes](https://github.com/yousty/yes) framework.
|
|
4
4
|
|
|
5
|
+
## Compatibility with pg_eventstore
|
|
6
|
+
|
|
7
|
+
⚠️ **pg_eventstore 3.x requires yes-core >= 2.1.1.** pg_eventstore 3.0.0 generates event
|
|
8
|
+
ids with `SecureRandom.uuid_v7` (it moved the default off the database's
|
|
9
|
+
`gen_random_uuid()`, which always produced v4). Those ids arrive as `causation_id` /
|
|
10
|
+
`correlation_id`, and `Types::UUID` accepted only v4 before 2.1.1 — so
|
|
11
|
+
`TransactionDetails.new` raised `Dry::Struct::Error`, the event handler failed, and the
|
|
12
|
+
subscription died once its restarts ran out.
|
|
13
|
+
|
|
14
|
+
`Types::UUID` now accepts any RFC 9562 version (1-8) while still requiring a valid
|
|
15
|
+
version and variant nibble.
|
|
16
|
+
|
|
17
|
+
|
|
5
18
|
## Installation
|
|
6
19
|
|
|
7
20
|
Add this line to your application's Gemfile:
|
|
@@ -3,22 +3,25 @@
|
|
|
3
3
|
module Yes
|
|
4
4
|
module Core
|
|
5
5
|
module ActiveJobSerializers
|
|
6
|
-
# ActiveJob serializer for
|
|
6
|
+
# ActiveJob serializer for {Yes::Core::Commands::Group} (legacy stateless
|
|
7
|
+
# cross-aggregate groups) and {Yes::Core::Commands::CommandGroup}
|
|
8
|
+
# (aggregate-DSL groups). Both round-trip through `to_h` / `Class.new`.
|
|
7
9
|
class CommandGroupSerializer < ActiveJob::Serializers::ObjectSerializer
|
|
8
10
|
# @param argument [Object] the argument to check
|
|
9
11
|
# @return [Boolean] true if the argument can be serialized
|
|
10
12
|
def serialize?(argument)
|
|
11
|
-
argument.is_a?
|
|
13
|
+
argument.is_a?(Yes::Core::Commands::Group) ||
|
|
14
|
+
argument.is_a?(Yes::Core::Commands::CommandGroup)
|
|
12
15
|
end
|
|
13
16
|
|
|
14
|
-
# @param command_group [Yes::Core::Commands::Group]
|
|
17
|
+
# @param command_group [Yes::Core::Commands::Group, Yes::Core::Commands::CommandGroup]
|
|
15
18
|
# @return [Hash] the serialized representation
|
|
16
19
|
def serialize(command_group)
|
|
17
20
|
super(command_group.to_h.merge(_type: command_group.class.name))
|
|
18
21
|
end
|
|
19
22
|
|
|
20
23
|
# @param hash [Hash] the serialized representation
|
|
21
|
-
# @return [Yes::Core::Commands::Group]
|
|
24
|
+
# @return [Yes::Core::Commands::Group, Yes::Core::Commands::CommandGroup]
|
|
22
25
|
def deserialize(hash)
|
|
23
26
|
symbolized_hash = hash.deep_symbolize_keys
|
|
24
27
|
Object.const_get(symbolized_hash[:_type]).new(symbolized_hash.except(:_aj_serialized, :_type))
|
|
@@ -36,7 +36,8 @@ module Yes
|
|
|
36
36
|
|
|
37
37
|
raise_command_unauthorized_error!(decision)
|
|
38
38
|
end
|
|
39
|
-
otl_trackable :call,
|
|
39
|
+
otl_trackable :call,
|
|
40
|
+
OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Cerbos Authorize Command', track_sql: true)
|
|
40
41
|
|
|
41
42
|
private
|
|
42
43
|
|
|
@@ -60,6 +61,10 @@ module Yes
|
|
|
60
61
|
raise self::CommandNotAuthorized, msg
|
|
61
62
|
end
|
|
62
63
|
|
|
64
|
+
# Loads the resource the command acts on. Commands batched into one request
|
|
65
|
+
# commonly target the same resource, so the lookup is cached for the duration
|
|
66
|
+
# of the authorization pass (see {LookupCache}).
|
|
67
|
+
#
|
|
63
68
|
# @param command [Yes::Core::Command] command to authorize
|
|
64
69
|
# @return [ActiveRecord::Base] resource to authorize
|
|
65
70
|
# @raise [StandardError] if RESOURCE[:name] or RESOURCE[:read_model] is not defined
|
|
@@ -70,7 +75,10 @@ module Yes
|
|
|
70
75
|
raise StandardError, message
|
|
71
76
|
end
|
|
72
77
|
|
|
73
|
-
read_model(command)
|
|
78
|
+
model = read_model(command)
|
|
79
|
+
id = command.send("#{self::RESOURCE[:name]}_id")
|
|
80
|
+
|
|
81
|
+
LookupCache.fetch([:resource, model, id]) { model.find_by(id:) }
|
|
74
82
|
end
|
|
75
83
|
|
|
76
84
|
# Returns the appropriate read model class for the command.
|
|
@@ -145,12 +153,19 @@ module Yes
|
|
|
145
153
|
resource&.try(:auth_attributes)&.as_json || {}
|
|
146
154
|
end
|
|
147
155
|
|
|
156
|
+
# Builds the principal data for the request. It is derived purely from the
|
|
157
|
+
# auth data, which is the same for every command of a batch, so it is built
|
|
158
|
+
# once per authorization pass (see {LookupCache}). The result is shared
|
|
159
|
+
# between commands and must not be mutated.
|
|
160
|
+
#
|
|
148
161
|
# @param auth_data [Hash] authorization data
|
|
149
162
|
# @return [Hash] principal data for Cerbos check_resource
|
|
150
163
|
def principal_data(auth_data)
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
)
|
|
164
|
+
data = auth_data.with_indifferent_access
|
|
165
|
+
|
|
166
|
+
LookupCache.fetch([:principal_data, data]) do
|
|
167
|
+
Yes::Core.configuration.cerbos_principal_data_builder.call(data)
|
|
168
|
+
end
|
|
154
169
|
end
|
|
155
170
|
|
|
156
171
|
# @param auth_data [Hash]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module Authorization
|
|
6
|
+
# Scoped memoization for the read-only lookups an authorization pass repeats.
|
|
7
|
+
#
|
|
8
|
+
# Authorizing a batch of commands resolves the same two things once per command:
|
|
9
|
+
# the principal data (which depends only on the request's auth data) and the
|
|
10
|
+
# authorized resource (which the commands of a batch commonly share). Both are
|
|
11
|
+
# pure reads, and a batch is authorized in full before any of its commands is
|
|
12
|
+
# executed, so no write can invalidate them while the pass is running.
|
|
13
|
+
#
|
|
14
|
+
# Caching is only active inside {.with_scope}. Outside one, {.fetch} just yields,
|
|
15
|
+
# so callers keep their uncached behaviour unless they opt in. The store lives in
|
|
16
|
+
# ActiveSupport::IsolatedExecutionState, which keeps it per thread/fiber, and
|
|
17
|
+
# {.with_scope} always clears it on the way out so nothing leaks into the next
|
|
18
|
+
# request.
|
|
19
|
+
#
|
|
20
|
+
# Cached values are shared by every {.fetch} for the same key, so callers must
|
|
21
|
+
# treat them as read-only.
|
|
22
|
+
#
|
|
23
|
+
# @example Caching the lookups of one authorization pass
|
|
24
|
+
# LookupCache.with_scope do
|
|
25
|
+
# commands.each { |command| authorizer_for(command).call(command, auth_data) }
|
|
26
|
+
# end
|
|
27
|
+
class LookupCache
|
|
28
|
+
STORE_KEY = :yes_core_authorization_lookup_cache
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
# Runs the block with caching enabled, clearing the cache afterwards.
|
|
32
|
+
# A nested scope reuses the cache of the outermost one and leaves clearing
|
|
33
|
+
# to it.
|
|
34
|
+
#
|
|
35
|
+
# @yield the block to run with caching enabled
|
|
36
|
+
# @return [Object] the block's return value
|
|
37
|
+
def with_scope
|
|
38
|
+
return yield if active?
|
|
39
|
+
|
|
40
|
+
ActiveSupport::IsolatedExecutionState[STORE_KEY] = {}
|
|
41
|
+
|
|
42
|
+
begin
|
|
43
|
+
yield
|
|
44
|
+
ensure
|
|
45
|
+
ActiveSupport::IsolatedExecutionState.delete(STORE_KEY)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns the value cached under key, computing it via the block on a miss.
|
|
50
|
+
# Without an open scope the block's value is returned uncached.
|
|
51
|
+
#
|
|
52
|
+
# @param key [Object] cache key
|
|
53
|
+
# @yield computes the value when it is not cached yet
|
|
54
|
+
# @return [Object] the cached or freshly computed value
|
|
55
|
+
def fetch(key)
|
|
56
|
+
return yield unless active?
|
|
57
|
+
|
|
58
|
+
store = ActiveSupport::IsolatedExecutionState[STORE_KEY]
|
|
59
|
+
return store[key] if store.key?(key)
|
|
60
|
+
|
|
61
|
+
store[key] = yield
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [Boolean] whether a scope is currently open
|
|
65
|
+
def active?
|
|
66
|
+
ActiveSupport::IsolatedExecutionState.key?(STORE_KEY)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -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)
|
|
@@ -169,7 +174,8 @@ module Yes
|
|
|
169
174
|
PgEventstore.client.append_to_stream(
|
|
170
175
|
utils.build_stream(metadata: sub_cmd.metadata || {}),
|
|
171
176
|
event,
|
|
172
|
-
options: { expected_revision: :any }
|
|
177
|
+
options: { expected_revision: :any },
|
|
178
|
+
middlewares: Middlewares.for_write
|
|
173
179
|
)
|
|
174
180
|
end
|
|
175
181
|
|
|
@@ -90,7 +90,8 @@ module Yes
|
|
|
90
90
|
PgEventstore.client.append_to_stream(
|
|
91
91
|
command_utilities.build_stream(metadata:),
|
|
92
92
|
event,
|
|
93
|
-
options: { expected_revision: }
|
|
93
|
+
options: { expected_revision: },
|
|
94
|
+
middlewares: Middlewares.for_write
|
|
94
95
|
).tap { otl_record_response(_1) }
|
|
95
96
|
end
|
|
96
97
|
|
|
@@ -105,16 +106,20 @@ module Yes
|
|
|
105
106
|
name: aggregate_data[:name],
|
|
106
107
|
id: aggregate_data[:id]
|
|
107
108
|
)
|
|
108
|
-
|
|
109
|
+
stream_revision = command_utilities.stream_revision(stream)
|
|
109
110
|
aggregate_revision = aggregate_data[:revision].call
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
next if normalized_revision == expected_revision
|
|
111
|
+
expected_revision = aggregate_revision == -1 ? :no_stream : aggregate_revision
|
|
112
|
+
next if expected_revision == stream_revision
|
|
113
113
|
|
|
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.
|
|
114
118
|
raise PgEventstore::WrongExpectedRevisionError.new(
|
|
115
|
-
revision:
|
|
119
|
+
revision: stream_revision,
|
|
116
120
|
expected_revision:,
|
|
117
|
-
stream
|
|
121
|
+
stream:,
|
|
122
|
+
verdict: :unmatched_stream_revision
|
|
118
123
|
)
|
|
119
124
|
end
|
|
120
125
|
end
|
|
@@ -175,7 +180,7 @@ module Yes
|
|
|
175
180
|
timestamp: result.created_at,
|
|
176
181
|
attributes: {
|
|
177
182
|
'event.type' => result.type,
|
|
178
|
-
'event.
|
|
183
|
+
'event.link_global_position' => result.link_global_position || '',
|
|
179
184
|
'global_position' => result.global_position,
|
|
180
185
|
'stream' => result.stream.to_json,
|
|
181
186
|
'stream.revision' => result.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
|
|
@@ -109,10 +109,15 @@ module Yes
|
|
|
109
109
|
@commands = build_commands
|
|
110
110
|
end
|
|
111
111
|
|
|
112
|
-
# @return [Hash] hash form for serialization
|
|
113
|
-
#
|
|
112
|
+
# @return [Hash] hash form for serialization — the FLAT input payload
|
|
113
|
+
# merged with reserved keys (transaction/origin/batch_id/metadata/
|
|
114
|
+
# command_id). This shape round-trips cleanly: calling
|
|
115
|
+
# `self.class.new(cmd.to_h)` produces an equivalent group with the
|
|
116
|
+
# same `payload`. Used by
|
|
117
|
+
# {Yes::Core::ActiveJobSerializers::CommandGroupSerializer} and by
|
|
118
|
+
# the Command API controller's `add_metadata`.
|
|
114
119
|
def to_h
|
|
115
|
-
merged =
|
|
120
|
+
merged = payload.merge(group_attributes.to_h)
|
|
116
121
|
transaction ? merged.merge(transaction:) : merged
|
|
117
122
|
end
|
|
118
123
|
|
|
@@ -79,14 +79,13 @@ module Yes
|
|
|
79
79
|
end
|
|
80
80
|
|
|
81
81
|
# Executes a single command on its aggregate
|
|
82
|
-
# @param cmd [Command] the command to execute
|
|
83
|
-
# @return [Response] response from executing the command
|
|
82
|
+
# @param cmd [Command, Yes::Core::Commands::CommandGroup] the command to execute
|
|
83
|
+
# @return [Response, Yes::Core::Commands::CommandGroupResponse] response from executing the command
|
|
84
84
|
def run_command(cmd)
|
|
85
85
|
command_helper = Yes::Core::Commands::Helper.new(cmd)
|
|
86
86
|
draft = draft?(cmd)
|
|
87
87
|
aggregate = aggregate_class(cmd).new(cmd.aggregate_id, draft:)
|
|
88
88
|
I18n.with_locale(command_helper.command_locale) do
|
|
89
|
-
# Pass payload as first argument, guards as option
|
|
90
89
|
aggregate.public_send(command_helper.command_name, cmd.to_h, guards: !draft)
|
|
91
90
|
end
|
|
92
91
|
end
|
|
@@ -103,21 +102,43 @@ module Yes
|
|
|
103
102
|
end
|
|
104
103
|
|
|
105
104
|
# Checks if a guard evaluator exists for the given command
|
|
106
|
-
#
|
|
105
|
+
#
|
|
106
|
+
# Aggregate-DSL command groups register their guard evaluator under a
|
|
107
|
+
# dedicated registry type, so they are resolved through a different
|
|
108
|
+
# configuration lookup than single commands.
|
|
109
|
+
#
|
|
110
|
+
# @param cmd [Command, Yes::Core::Commands::CommandGroup] The command to check
|
|
107
111
|
# @return [Boolean] true if a guard evaluator exists
|
|
108
112
|
# @raise [UnregisteredCommand] if no guard evaluator is found for the command
|
|
109
113
|
def guard_evaluator_exists?(cmd)
|
|
110
114
|
command_helper = Yes::Core::Commands::Helper.new(cmd)
|
|
111
115
|
|
|
112
|
-
klass =
|
|
113
|
-
command_helper.subject,
|
|
114
|
-
command_helper.command_name)
|
|
116
|
+
klass = guard_evaluator_class_for(cmd, command_helper)
|
|
115
117
|
|
|
116
118
|
raise UnregisteredCommand, "Unregistered command: #{cmd.class}" unless klass
|
|
117
119
|
|
|
118
120
|
true
|
|
119
121
|
end
|
|
120
122
|
|
|
123
|
+
# Resolves the guard evaluator class for a command, dispatching to the
|
|
124
|
+
# command-group registry for {Yes::Core::Commands::CommandGroup} and to
|
|
125
|
+
# the single-command registry otherwise.
|
|
126
|
+
#
|
|
127
|
+
# @param cmd [Command, Yes::Core::Commands::CommandGroup] The command
|
|
128
|
+
# @param command_helper [Yes::Core::Commands::Helper] helper for the command
|
|
129
|
+
# @return [Class, nil] the registered guard evaluator class or nil
|
|
130
|
+
def guard_evaluator_class_for(cmd, command_helper)
|
|
131
|
+
if cmd.is_a?(Yes::Core::Commands::CommandGroup)
|
|
132
|
+
Yes::Core.configuration.command_group_guard_evaluator_class(command_helper.command_context,
|
|
133
|
+
command_helper.subject,
|
|
134
|
+
command_helper.command_name)
|
|
135
|
+
else
|
|
136
|
+
Yes::Core.configuration.guard_evaluator_class(command_helper.command_context,
|
|
137
|
+
command_helper.subject,
|
|
138
|
+
command_helper.command_name)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
121
142
|
# Ensures handlers exist for all commands
|
|
122
143
|
# @param commands [Array<Command>] The commands to check
|
|
123
144
|
# @return [Boolean] true if handlers exist for all commands
|
|
@@ -118,7 +118,8 @@ module Yes
|
|
|
118
118
|
PgEventstore.client.append_to_stream(
|
|
119
119
|
stream,
|
|
120
120
|
event,
|
|
121
|
-
options: { expected_revision: subject_stream_revision }
|
|
121
|
+
options: { expected_revision: subject_stream_revision },
|
|
122
|
+
middlewares: Middlewares.for_write
|
|
122
123
|
).tap { otl_record_response(_1) }
|
|
123
124
|
end
|
|
124
125
|
otl_trackable :publish_event, OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Publish Event', span_kind: :producer)
|
|
@@ -190,20 +191,28 @@ module Yes
|
|
|
190
191
|
expected = expected_revision(stream)
|
|
191
192
|
next if revision == expected
|
|
192
193
|
|
|
193
|
-
revision_error!(
|
|
194
|
+
revision_error!(expected || -1, revision || -1, stream)
|
|
194
195
|
end
|
|
195
196
|
end
|
|
196
197
|
|
|
197
|
-
#
|
|
198
|
-
#
|
|
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
|
|
199
203
|
# @param stream [PgEventstore::Stream]
|
|
200
204
|
def revision_error!(revision, expected_revision, stream)
|
|
201
|
-
|
|
205
|
+
# pg_eventstore 3.0 requires `verdict:`, which selects the error's
|
|
206
|
+
# message. verify_revisions! only calls this when the two revisions
|
|
207
|
+
# differ, which is exactly :unmatched_stream_revision.
|
|
208
|
+
PgEventstore::WrongExpectedRevisionError.new(
|
|
209
|
+
revision:, expected_revision:, stream:, verdict: :unmatched_stream_revision
|
|
210
|
+
).tap do |error|
|
|
202
211
|
self.class.current_span&.status = ::OpenTelemetry::Trace::Status.error('Wrong expected revision')
|
|
203
212
|
self.class.current_span&.add_attributes(
|
|
204
213
|
{
|
|
205
214
|
current_revision: revision,
|
|
206
|
-
expected_revision
|
|
215
|
+
expected_revision:,
|
|
207
216
|
stream: stream.to_json
|
|
208
217
|
}.stringify_keys
|
|
209
218
|
)
|
|
@@ -277,7 +286,7 @@ module Yes
|
|
|
277
286
|
timestamp: result.created_at,
|
|
278
287
|
attributes: {
|
|
279
288
|
'event.type' => result.type,
|
|
280
|
-
'event.
|
|
289
|
+
'event.link_global_position' => result.link_global_position || '',
|
|
281
290
|
'global_position' => result.global_position,
|
|
282
291
|
'stream' => result.stream.to_json,
|
|
283
292
|
'stream.revision' => result.stream_revision,
|
|
@@ -287,7 +287,7 @@ module Yes
|
|
|
287
287
|
# @return [Enumerator]
|
|
288
288
|
def load_events(stream, options: {}, skip_decryption: true)
|
|
289
289
|
options = { direction: 'Backwards' }.merge(options)
|
|
290
|
-
middlewares = Middlewares.without(
|
|
290
|
+
middlewares = Middlewares.without(Middlewares::ENCRYPTOR) if skip_decryption
|
|
291
291
|
PgEventstore.client.read_paginated(stream, options:, middlewares:)
|
|
292
292
|
end
|
|
293
293
|
|
|
@@ -331,6 +331,23 @@ module Yes
|
|
|
331
331
|
aggregate_class(context_name, aggregate_name, command_name.to_s.underscore.to_sym, :guard_evaluator)
|
|
332
332
|
end
|
|
333
333
|
|
|
334
|
+
# Retrieve a guard evaluator class for a specific command group.
|
|
335
|
+
#
|
|
336
|
+
# Aggregate-DSL command groups register their guard evaluator under the
|
|
337
|
+
# dedicated `:command_group_guard_evaluator` type (see
|
|
338
|
+
# {#register_command_group_guard_evaluator_class}), not `:guard_evaluator`,
|
|
339
|
+
# so a group cannot be resolved through {#guard_evaluator_class}.
|
|
340
|
+
#
|
|
341
|
+
# @param context_name [Symbol, String] The context for the aggregate
|
|
342
|
+
# @param aggregate_name [Symbol, String] The name of the aggregate
|
|
343
|
+
# @param group_name [Symbol, String] The name of the command group
|
|
344
|
+
# @return [Class, nil] The registered guard evaluator class or nil if not found
|
|
345
|
+
# @example
|
|
346
|
+
# evaluator = command_group_guard_evaluator_class(:companies, :apprenticeship, :create_apprenticeship)
|
|
347
|
+
def command_group_guard_evaluator_class(context_name, aggregate_name, group_name)
|
|
348
|
+
aggregate_class(context_name, aggregate_name, group_name.to_s.underscore.to_sym, :command_group_guard_evaluator)
|
|
349
|
+
end
|
|
350
|
+
|
|
334
351
|
# List all registered classes across all aggregates and contexts
|
|
335
352
|
# @return [Hash] A complete hash of all registered classes
|
|
336
353
|
# @example
|
|
@@ -40,13 +40,13 @@ module Yes
|
|
|
40
40
|
def decrypt_attributes(key:, data:, attributes: {}) # rubocop:disable Lint/UnusedMethodArgument
|
|
41
41
|
return data unless key
|
|
42
42
|
|
|
43
|
-
res = key_repository.decrypt(key:, message: data[
|
|
43
|
+
res = key_repository.decrypt(key:, message: data[DataEncryptor::CIPHERTEXT_KEY])
|
|
44
44
|
return data if res.failure?
|
|
45
45
|
|
|
46
46
|
decrypted_text = res.value!
|
|
47
47
|
decrypted = JSON.parse(decrypted_text.attributes[:message]).transform_keys(&:to_s)
|
|
48
48
|
decrypted.each { |k, value| data[k] = value if data.key?(k) }
|
|
49
|
-
data.delete(
|
|
49
|
+
data.delete(DataEncryptor::CIPHERTEXT_KEY)
|
|
50
50
|
data
|
|
51
51
|
end
|
|
52
52
|
|
|
@@ -10,6 +10,10 @@ module Yes
|
|
|
10
10
|
# encryptor.encrypted_data
|
|
11
11
|
# encryptor.encryption_metadata
|
|
12
12
|
class DataEncryptor
|
|
13
|
+
# Data key holding the ciphertext of all encrypted attributes. Its presence means the data is
|
|
14
|
+
# currently encrypted: it is written here and removed by {DataDecryptor}.
|
|
15
|
+
CIPHERTEXT_KEY = 'es_encrypted'
|
|
16
|
+
|
|
13
17
|
# @return [Hash] the encrypted data
|
|
14
18
|
attr_reader :encrypted_data
|
|
15
19
|
|
|
@@ -51,8 +55,8 @@ module Yes
|
|
|
51
55
|
def encrypt_attributes(key:, data:, attributes:)
|
|
52
56
|
text = JSON.generate(data.select { |hash_key, _value| attributes.include?(hash_key.to_s) })
|
|
53
57
|
encrypted = key_repository.encrypt(key:, message: text).value!
|
|
54
|
-
attributes.each { |att| data[att.to_s] =
|
|
55
|
-
data[
|
|
58
|
+
attributes.each { |att| data[att.to_s] = CIPHERTEXT_KEY if data.key?(att.to_s) }
|
|
59
|
+
data[CIPHERTEXT_KEY] = encrypted.attributes[:message]
|
|
56
60
|
data
|
|
57
61
|
end
|
|
58
62
|
end
|
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
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
# Reports a permanently dead pg_eventstore subscription to Sentry.
|
|
6
|
+
#
|
|
7
|
+
# pg_eventstore calls its +failed_subscription_notifier+ exactly once, when a
|
|
8
|
+
# subscription exhausts its restarts and stays dead — it is the gem's only
|
|
9
|
+
# death signal, and without it that death is silent. Per-failure
|
|
10
|
+
# errors are only recorded on the subscription row, never raised into Sentry.
|
|
11
|
+
class FailedSubscriptionNotifier
|
|
12
|
+
# @param subscription [PgEventstore::Subscription]
|
|
13
|
+
# @param error [StandardError]
|
|
14
|
+
# @return [void]
|
|
15
|
+
def call(subscription, error)
|
|
16
|
+
Sentry.with_scope do |scope|
|
|
17
|
+
scope.set_tags(failed_subscription_notifier: true) # used in Sentry Alerts
|
|
18
|
+
|
|
19
|
+
Sentry.capture_exception(
|
|
20
|
+
error,
|
|
21
|
+
# a death report must never be swallowed by Sentry's excluded_exceptions
|
|
22
|
+
hint: { ignore_exclusions: true },
|
|
23
|
+
extra: { id: subscription.id, set: subscription.set, name: subscription.name }
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -5,9 +5,12 @@ module Yes
|
|
|
5
5
|
module Middlewares
|
|
6
6
|
# PgEventstore middleware for encrypting/decrypting event data.
|
|
7
7
|
#
|
|
8
|
+
# Register it through {Middlewares.register_encryptor} rather than by hand, so its write-only
|
|
9
|
+
# counterpart ({WriteEncryptor}) is always registered alongside it.
|
|
10
|
+
#
|
|
8
11
|
# @example
|
|
9
12
|
# PgEventstore.configure do |config|
|
|
10
|
-
#
|
|
13
|
+
# Yes::Core::Middlewares.register_encryptor(key_repository, config:)
|
|
11
14
|
# end
|
|
12
15
|
class Encryptor
|
|
13
16
|
include PgEventstore::Middleware
|
|
@@ -24,6 +27,11 @@ module Yes
|
|
|
24
27
|
# @return [PgEventstore::Event]
|
|
25
28
|
def serialize(event)
|
|
26
29
|
return event unless event.class.respond_to?(:encryption_schema)
|
|
30
|
+
# Idempotence guard. With {WriteEncryptor} registered, the DEFAULT middleware list holds two
|
|
31
|
+
# serialize-capable encryptors, so an append that omits `middlewares:` would encrypt twice. The
|
|
32
|
+
# second pass would encrypt the sentinels written by the first and overwrite the real ciphertext,
|
|
33
|
+
# which cannot be recovered. It also guards re-appending an event that was read at rest.
|
|
34
|
+
return event if event.data[DataEncryptor::CIPHERTEXT_KEY].present?
|
|
27
35
|
|
|
28
36
|
encryptor = DataEncryptor.new(
|
|
29
37
|
data: event.data, schema: event.class.encryption_schema, repository: key_repository
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module Middlewares
|
|
6
|
+
# Encrypts on the way in exactly like {Encryptor}, but does not decrypt on the way out.
|
|
7
|
+
#
|
|
8
|
+
# pg_eventstore 3.0 runs every registered middleware's #deserialize on the events returned by
|
|
9
|
+
# #append_to_stream, not only on the ones returned by reads. Nothing on the write path reads `data` off
|
|
10
|
+
# that returned event, so decrypting it costs two uncached HTTP calls to the encryptor service (a key
|
|
11
|
+
# lookup and a decrypt) per encrypted event, for a payload that is immediately discarded.
|
|
12
|
+
#
|
|
13
|
+
# #serialize is inherited rather than reimplemented on purpose: encryption at rest must never differ
|
|
14
|
+
# between the read and the write variant.
|
|
15
|
+
#
|
|
16
|
+
# Registered alongside {Encryptor} by {Middlewares.register_encryptor} and selected by
|
|
17
|
+
# {Middlewares.for_write}. The read path keeps using {Encryptor}, unchanged.
|
|
18
|
+
#
|
|
19
|
+
# @example
|
|
20
|
+
# PgEventstore.client.append_to_stream(stream, event, middlewares: Yes::Core::Middlewares.for_write)
|
|
21
|
+
class WriteEncryptor < Encryptor
|
|
22
|
+
# Deliberately does nothing: the caller does not read the returned event's data.
|
|
23
|
+
#
|
|
24
|
+
# Returns the event rather than nil. pg_eventstore itself ignores the return value, but middlewares
|
|
25
|
+
# are also invoked directly in places that use it.
|
|
26
|
+
#
|
|
27
|
+
# @param event [PgEventstore::Event]
|
|
28
|
+
# @return [PgEventstore::Event]
|
|
29
|
+
def deserialize(event)
|
|
30
|
+
event
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
data/lib/yes/core/middlewares.rb
CHANGED
|
@@ -3,9 +3,54 @@
|
|
|
3
3
|
module Yes
|
|
4
4
|
module Core
|
|
5
5
|
module Middlewares
|
|
6
|
+
# Config key of the encryptor used on read paths: encrypts on #serialize, decrypts on #deserialize.
|
|
7
|
+
ENCRYPTOR = :encryptor
|
|
8
|
+
# Config key of the encryptor used on write paths: encrypts on #serialize, no-op on #deserialize.
|
|
9
|
+
WRITE_ENCRYPTOR = :write_encryptor
|
|
10
|
+
|
|
6
11
|
class << self
|
|
12
|
+
# Registers both encryptor middlewares against the same key repository.
|
|
13
|
+
#
|
|
14
|
+
# Always use this instead of assigning config.middlewares[:encryptor] by hand: registering the
|
|
15
|
+
# decrypting encryptor without its write-only twin silently doubles the encryptor round trips every
|
|
16
|
+
# encrypted append performs (see {WriteEncryptor}).
|
|
17
|
+
#
|
|
18
|
+
# Mutates the given config in place rather than opening its own PgEventstore.configure block, because
|
|
19
|
+
# PgEventstore.configure takes a non-reentrant mutex - nesting one inside another deadlocks.
|
|
20
|
+
#
|
|
21
|
+
# @param key_repository [#find, #create, #encrypt, #decrypt]
|
|
22
|
+
# @param config [PgEventstore::Config] the config yielded by PgEventstore.configure
|
|
23
|
+
# @return [void]
|
|
24
|
+
def register_encryptor(key_repository, config: PgEventstore.config)
|
|
25
|
+
config.middlewares[ENCRYPTOR] = Encryptor.new(key_repository)
|
|
26
|
+
config.middlewares[WRITE_ENCRYPTOR] = WriteEncryptor.new(key_repository)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Middleware keys to pass to #append_to_stream: every configured middleware, with the decrypting
|
|
30
|
+
# encryptor swapped for the write-only one.
|
|
31
|
+
#
|
|
32
|
+
# Derived from the live config, never hard-coded. PgEventstore::Client resolves a passed list with
|
|
33
|
+
# `config.middlewares.slice(*list)`, which silently drops names that are not registered - so a literal
|
|
34
|
+
# list would resolve to one with NO encryptor at all against a config that registered it under a
|
|
35
|
+
# different key, and would write plaintext at rest undetectably. Deriving the list makes that
|
|
36
|
+
# impossible, and picks up any middleware added later for free.
|
|
37
|
+
#
|
|
38
|
+
# Falls back to the full list when {WRITE_ENCRYPTOR} is not registered. That is correct, just as slow
|
|
39
|
+
# as before - unlike a hard-coded list, which would drop encryption altogether.
|
|
40
|
+
#
|
|
41
|
+
# @return [Array<Symbol>]
|
|
42
|
+
def for_write
|
|
43
|
+
keys = PgEventstore.config.middlewares.keys
|
|
44
|
+
return keys unless keys.include?(WRITE_ENCRYPTOR)
|
|
45
|
+
|
|
46
|
+
keys - [ENCRYPTOR]
|
|
47
|
+
end
|
|
48
|
+
|
|
7
49
|
# Returns middleware keys excluding the specified one.
|
|
8
50
|
#
|
|
51
|
+
# Note that excluding {ENCRYPTOR} still yields a list containing {WRITE_ENCRYPTOR}, whose #deserialize
|
|
52
|
+
# is a no-op - so `without(:encryptor)` keeps meaning "read the data as it is stored".
|
|
53
|
+
#
|
|
9
54
|
# @param middleware_name [Symbol] the middleware key to exclude
|
|
10
55
|
# @return [Array<Symbol>] remaining middleware keys
|
|
11
56
|
def without(middleware_name)
|
data/lib/yes/core/railtie.rb
CHANGED
|
@@ -16,6 +16,11 @@ module Yes
|
|
|
16
16
|
with_indifferent_access: Yes::Core::Middlewares::WithIndifferentAccess.new,
|
|
17
17
|
timestamp: Yes::Core::Middlewares::Timestamp.new
|
|
18
18
|
}
|
|
19
|
+
|
|
20
|
+
# pg_eventstore's only death signal — without it a subscription that
|
|
21
|
+
# exhausts its restarts dies silently: per-failure errors are only recorded
|
|
22
|
+
# on the subscription row, never raised.
|
|
23
|
+
config.failed_subscription_notifier = FailedSubscriptionNotifier.new if defined?(Sentry)
|
|
19
24
|
end
|
|
20
25
|
end
|
|
21
26
|
|
|
@@ -49,6 +54,21 @@ module Yes
|
|
|
49
54
|
PgEventstore.logger ||= Rails.logger if ENV['PG_ES_LOGGING'] == 'true'
|
|
50
55
|
end
|
|
51
56
|
|
|
57
|
+
# The one misconfiguration Middlewares.for_write cannot fix by itself: a decrypting encryptor registered
|
|
58
|
+
# without its write-only twin. for_write then falls back to the full list, so writes stay CORRECT - they
|
|
59
|
+
# just keep paying an encryptor key lookup and decrypt per encrypted event, for data nobody reads.
|
|
60
|
+
# Warned about once at boot rather than on every append.
|
|
61
|
+
config.after_initialize do
|
|
62
|
+
middlewares = PgEventstore.config.middlewares
|
|
63
|
+
next unless middlewares.key?(Yes::Core::Middlewares::ENCRYPTOR)
|
|
64
|
+
next if middlewares.key?(Yes::Core::Middlewares::WRITE_ENCRYPTOR)
|
|
65
|
+
|
|
66
|
+
Rails.logger.warn(
|
|
67
|
+
'PgEventstore middleware :encryptor is registered without :write_encryptor, so append_to_stream ' \
|
|
68
|
+
'decrypts every event it returns. Register both with Yes::Core::Middlewares.register_encryptor.'
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
52
72
|
# Load aggregate shortcuts when Rails console starts
|
|
53
73
|
console do
|
|
54
74
|
Yes::Core::Utils::AggregateShortcuts.load!
|
|
@@ -16,7 +16,7 @@ module Yes
|
|
|
16
16
|
# @param event [Yes::Core::Event]
|
|
17
17
|
# @return [void]
|
|
18
18
|
def append_event(stream, event)
|
|
19
|
-
PgEventstore.client.append_to_stream(stream, event)
|
|
19
|
+
PgEventstore.client.append_to_stream(stream, event, middlewares: Yes::Core::Middlewares.for_write)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
# Appends an event to a stream and reloads it
|
data/lib/yes/core/types.rb
CHANGED
|
@@ -20,7 +20,14 @@ module Yes
|
|
|
20
20
|
|
|
21
21
|
EMPTY_STRING = /\A\s*\z/
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
# Version nibble is [1-8], not a hard-coded 4. pg_eventstore 3.0.0 moved event-id
|
|
24
|
+
# generation off the database's gen_random_uuid() (always v4) to
|
|
25
|
+
# SecureRandom.uuid_v7, and those ids reach us as causation_id / correlation_id.
|
|
26
|
+
# A v4-only pattern makes TransactionDetails raise Dry::Struct::Error, which fails
|
|
27
|
+
# the event handler and kills the subscription once its restarts run out.
|
|
28
|
+
#
|
|
29
|
+
# Still a real constraint, per RFC 9562: version 1-8 and variant nibble 8/9/a/b.
|
|
30
|
+
UUID_REGEXP_BASE = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}/
|
|
24
31
|
UUID_REGEXP = /\A#{UUID_REGEXP_BASE}\z/i
|
|
25
32
|
|
|
26
33
|
DATE_TIME_REGEXP = /\A\d{4}-\d{1,2}-\d{1,2} ([0-1][0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?\z/i
|
|
@@ -22,7 +22,8 @@ module Yes
|
|
|
22
22
|
dupl
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
# Returns a hash with the keys flattened
|
|
25
|
+
# Returns a hash with the keys flattened. Scalar, nested-hash and
|
|
26
|
+
# array values all honour the `prefix` consistently.
|
|
26
27
|
#
|
|
27
28
|
# @param obj [Hash, Array] the object to flatten
|
|
28
29
|
# @param prefix [String] the key to use as a prefix for the keys in the hash
|
|
@@ -32,6 +33,10 @@ module Yes
|
|
|
32
33
|
# @example
|
|
33
34
|
# HashUtils.deep_flatten_hash({ name: 'A', otl_contexts: { root: { attr: 10, available: true } } })
|
|
34
35
|
# => {"name"=>"A", "otl_contexts.root.attr"=>10, "otl_contexts.root.available"=>true}
|
|
36
|
+
#
|
|
37
|
+
# @example with a prefix (arrays included)
|
|
38
|
+
# HashUtils.deep_flatten_hash({ id: 1, tags: [{ k: 'v' }] }, 'span')
|
|
39
|
+
# => {"span.id"=>1, "span.tags"=>[{"k"=>"v"}]}
|
|
35
40
|
def deep_flatten_hash(obj, prefix = nil, memo = {})
|
|
36
41
|
case obj
|
|
37
42
|
when Hash
|
|
@@ -42,7 +47,7 @@ module Yes
|
|
|
42
47
|
in String | Symbol, Hash
|
|
43
48
|
deep_flatten_hash(value, prefix ? "#{prefix}.#{key}" : key.to_s, memo)
|
|
44
49
|
in String | Symbol, Array
|
|
45
|
-
memo[key.to_s] = deep_flatten_hash(value)
|
|
50
|
+
memo[prefix ? "#{prefix}.#{key}" : key.to_s] = deep_flatten_hash(value)
|
|
46
51
|
in Array, _
|
|
47
52
|
memo[deep_flatten_hash(key)] = deep_flatten_hash(value)
|
|
48
53
|
else
|
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:
|
|
4
|
+
version: 2.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nico Ritsche
|
|
@@ -127,14 +127,14 @@ dependencies:
|
|
|
127
127
|
requirements:
|
|
128
128
|
- - "~>"
|
|
129
129
|
- !ruby/object:Gem::Version
|
|
130
|
-
version: '
|
|
130
|
+
version: '3.0'
|
|
131
131
|
type: :runtime
|
|
132
132
|
prerelease: false
|
|
133
133
|
version_requirements: !ruby/object:Gem::Requirement
|
|
134
134
|
requirements:
|
|
135
135
|
- - "~>"
|
|
136
136
|
- !ruby/object:Gem::Version
|
|
137
|
-
version: '
|
|
137
|
+
version: '3.0'
|
|
138
138
|
- !ruby/object:Gem::Dependency
|
|
139
139
|
name: rails
|
|
140
140
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -224,6 +224,7 @@ files:
|
|
|
224
224
|
- lib/yes/core/authorization/cerbos_client_provider.rb
|
|
225
225
|
- lib/yes/core/authorization/command_authorizer.rb
|
|
226
226
|
- lib/yes/core/authorization/command_cerbos_authorizer.rb
|
|
227
|
+
- lib/yes/core/authorization/lookup_cache.rb
|
|
227
228
|
- lib/yes/core/authorization/read_model_authorizer.rb
|
|
228
229
|
- lib/yes/core/authorization/read_models_authorizer.rb
|
|
229
230
|
- lib/yes/core/authorization/read_request_authorizer.rb
|
|
@@ -241,6 +242,8 @@ files:
|
|
|
241
242
|
- lib/yes/core/command_handling/read_model_recovery_service.rb
|
|
242
243
|
- lib/yes/core/command_handling/read_model_revision_guard.rb
|
|
243
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
|
|
244
247
|
- lib/yes/core/command_handling/state_updater.rb
|
|
245
248
|
- lib/yes/core/commands/bus.rb
|
|
246
249
|
- lib/yes/core/commands/command_group.rb
|
|
@@ -267,6 +270,7 @@ files:
|
|
|
267
270
|
- lib/yes/core/error_messages.rb
|
|
268
271
|
- lib/yes/core/event.rb
|
|
269
272
|
- lib/yes/core/event_class_resolver.rb
|
|
273
|
+
- lib/yes/core/failed_subscription_notifier.rb
|
|
270
274
|
- lib/yes/core/generators/read_models/add_pending_update_tracking_generator.rb
|
|
271
275
|
- lib/yes/core/generators/read_models/templates/add_pending_update_tracking.rb.erb
|
|
272
276
|
- lib/yes/core/generators/read_models/templates/migration.rb.erb
|
|
@@ -276,6 +280,7 @@ files:
|
|
|
276
280
|
- lib/yes/core/middlewares/encryptor.rb
|
|
277
281
|
- lib/yes/core/middlewares/timestamp.rb
|
|
278
282
|
- lib/yes/core/middlewares/with_indifferent_access.rb
|
|
283
|
+
- lib/yes/core/middlewares/write_encryptor.rb
|
|
279
284
|
- lib/yes/core/models/application_record.rb
|
|
280
285
|
- lib/yes/core/open_telemetry/otl_span.rb
|
|
281
286
|
- lib/yes/core/open_telemetry/trackable.rb
|