event_rail 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +47 -0
- data/CONTRIBUTING.md +14 -0
- data/MIT-LICENSE +20 -0
- data/README.md +364 -0
- data/SECURITY.md +3 -0
- data/lib/event_rail/contract.rb +44 -0
- data/lib/event_rail/current.rb +95 -0
- data/lib/event_rail/data.rb +4 -0
- data/lib/event_rail/envelope.rb +123 -0
- data/lib/event_rail/errors.rb +196 -0
- data/lib/event_rail/event.rb +258 -0
- data/lib/event_rail/internal/attribute_record.rb +272 -0
- data/lib/event_rail/internal/context.rb +32 -0
- data/lib/event_rail/internal/contract_index.rb +28 -0
- data/lib/event_rail/internal/event_serializer.rb +148 -0
- data/lib/event_rail/internal/execution.rb +86 -0
- data/lib/event_rail/internal/extensions.rb +69 -0
- data/lib/event_rail/internal/identity.rb +90 -0
- data/lib/event_rail/internal/notifications.rb +42 -0
- data/lib/event_rail/internal/portable_value.rb +108 -0
- data/lib/event_rail/internal/registry.rb +224 -0
- data/lib/event_rail/internal/stamping.rb +185 -0
- data/lib/event_rail/internal/subscriber_execution.rb +61 -0
- data/lib/event_rail/internal/timestamp.rb +85 -0
- data/lib/event_rail/internal/transaction.rb +33 -0
- data/lib/event_rail/internal/types.rb +441 -0
- data/lib/event_rail/job_context.rb +124 -0
- data/lib/event_rail/limits.rb +31 -0
- data/lib/event_rail/metadata.rb +94 -0
- data/lib/event_rail/portable_type.rb +35 -0
- data/lib/event_rail/publication.rb +33 -0
- data/lib/event_rail/publish.rb +88 -0
- data/lib/event_rail/railtie.rb +15 -0
- data/lib/event_rail/subscriptions.rb +59 -0
- data/lib/event_rail/version.rb +3 -0
- data/lib/event_rail.rb +52 -0
- metadata +184 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
require "active_model/attributes"
|
|
2
|
+
require "active_model/validations"
|
|
3
|
+
|
|
4
|
+
module EventRail
|
|
5
|
+
module Internal
|
|
6
|
+
class AttributeRecord
|
|
7
|
+
# Deliberately not ActiveModel::Model: that bundle also brings Conversion and
|
|
8
|
+
# Access, whose to_model, to_key, to_param, to_partial_path, persisted?, slice
|
|
9
|
+
# and values_at are view and form concerns. They are meaningless on an
|
|
10
|
+
# immutable historical fact and would become public API at 1.0.
|
|
11
|
+
include ActiveModel::Attributes
|
|
12
|
+
include ActiveModel::AttributeAssignment
|
|
13
|
+
include ActiveModel::Validations
|
|
14
|
+
|
|
15
|
+
DEFAULT_NOT_GIVEN = Object.new.freeze
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def attribute(name, cast_type = nil, default: DEFAULT_NOT_GIVEN, array: false, **options)
|
|
19
|
+
attribute_name = name.to_s
|
|
20
|
+
validate_attribute_name!(attribute_name)
|
|
21
|
+
|
|
22
|
+
type = Types.resolve(cast_type, array: array, options: options)
|
|
23
|
+
|
|
24
|
+
if default.equal?(DEFAULT_NOT_GIVEN)
|
|
25
|
+
super(attribute_name, type)
|
|
26
|
+
else
|
|
27
|
+
unless PortableValue.literal_default?(default)
|
|
28
|
+
raise DeclarationError, "#{self} attribute #{attribute_name.inspect} cannot use a callable default"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
super(attribute_name, type, default: type.cast(default))
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def reserved_attribute_names
|
|
36
|
+
%w[attributes data errors valid?].freeze
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def record_error_class
|
|
40
|
+
InvalidData
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
def validate_attribute_name!(name)
|
|
45
|
+
if reserved_attribute_names.include?(name)
|
|
46
|
+
raise DeclarationError, "#{self} cannot declare reserved attribute #{name.inspect}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Redeclaring an existing attribute is legitimate; its readers are ours.
|
|
50
|
+
return if attribute_types.key?(name)
|
|
51
|
+
|
|
52
|
+
shadowed = [ name, "#{name}=" ].find { |candidate| shadows_behavior?(candidate) }
|
|
53
|
+
return unless shadowed
|
|
54
|
+
|
|
55
|
+
raise DeclarationError,
|
|
56
|
+
"#{self} cannot declare attribute #{name.inspect} because #{shadowed.inspect} is already defined"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A reader can only shadow behavior a caller could have reached, so public
|
|
60
|
+
# and protected methods are the boundary. Ruby defines a large private
|
|
61
|
+
# vocabulary on every object -- format, select, open, p, raise -- and those
|
|
62
|
+
# are ordinary domain words that no reader can shadow, because no caller
|
|
63
|
+
# could have invoked them through the receiver. The record's own private
|
|
64
|
+
# helpers are the exception: those are reached internally, so a reader
|
|
65
|
+
# replacing one would break this class from the inside.
|
|
66
|
+
def shadows_behavior?(name)
|
|
67
|
+
return true if method_defined?(name)
|
|
68
|
+
return false unless private_method_defined?(name)
|
|
69
|
+
|
|
70
|
+
owner = instance_method(name).owner
|
|
71
|
+
owner.name.to_s.start_with?("EventRail")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Untrusted input: casts, validates, canonicalizes and freezes. State the
|
|
75
|
+
# caller already derived, such as reconstructed metadata, is installed
|
|
76
|
+
# before initialize runs, so no sentinel rides on the public signature.
|
|
77
|
+
#
|
|
78
|
+
# Trusted input arrives in written form and is read back through each
|
|
79
|
+
# declared type's `deserialize` before assignment, which is what lets a
|
|
80
|
+
# nested record preserve fields this version does not declare. Casting then
|
|
81
|
+
# sees values it already accepts, so the strict door stays strict without a
|
|
82
|
+
# second, laxer parser beside it.
|
|
83
|
+
def __event_rail_build__(declared, unknown: {}, state: {}, trusted: false)
|
|
84
|
+
values = trusted ? deserialize_declared(declared) : declared
|
|
85
|
+
|
|
86
|
+
record = allocate
|
|
87
|
+
state.each { |ivar, value| record.instance_variable_set(ivar, value) }
|
|
88
|
+
record.instance_variable_set(
|
|
89
|
+
:@unknown_attributes, PortableValue.raw(unknown, path: "unknown attributes")
|
|
90
|
+
)
|
|
91
|
+
record.send(:initialize, values)
|
|
92
|
+
record
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Partition is by declaration, so a field a newer producer added stays
|
|
96
|
+
# opaque payload without a reader instead of failing the reconstruction.
|
|
97
|
+
def __event_rail_reconstruct__(portable, state: {})
|
|
98
|
+
unless portable.is_a?(Hash) && portable.keys.all? { |key| key.is_a?(String) }
|
|
99
|
+
raise record_error_class, "trusted #{self} data must be a string-keyed hash"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
known_names = attribute_names
|
|
103
|
+
declared, unknown = portable.partition { |name, _value| known_names.include?(name) }.map(&:to_h)
|
|
104
|
+
__event_rail_build__(declared, unknown: unknown, state: state, trusted: true)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def deserialize_declared(declared)
|
|
108
|
+
types = attribute_types
|
|
109
|
+
|
|
110
|
+
declared.each_with_object({}) do |(name, value), result|
|
|
111
|
+
result[name] = begin
|
|
112
|
+
types[name].deserialize(value)
|
|
113
|
+
rescue Error => error
|
|
114
|
+
raise error.class.new("attribute #{name.inspect}: #{error.message}"), cause: error
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Trusted in-process copy: the source is an instance of this class whose
|
|
120
|
+
# payload is already cast, validated, canonicalized and deeply frozen.
|
|
121
|
+
# Varying state the payload does not depend on cannot invalidate it, so
|
|
122
|
+
# nothing needs recomputing.
|
|
123
|
+
def __event_rail_copy__(source, state: {})
|
|
124
|
+
copy = allocate
|
|
125
|
+
source.instance_variables.each do |ivar|
|
|
126
|
+
copy.instance_variable_set(ivar, source.instance_variable_get(ivar))
|
|
127
|
+
end
|
|
128
|
+
state.each { |ivar, value| copy.instance_variable_set(ivar, value) }
|
|
129
|
+
copy.errors
|
|
130
|
+
copy.freeze
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def initialize(attributes = nil, **keyword_attributes)
|
|
135
|
+
input = normalize_input(attributes, keyword_attributes)
|
|
136
|
+
declared, local_unknown = partition_attributes(input)
|
|
137
|
+
|
|
138
|
+
unless local_unknown.empty?
|
|
139
|
+
raise record_error_class, unknown_attributes_message(local_unknown)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# ActiveModel::Attributes#initialize takes no arguments; assignment is
|
|
143
|
+
# AttributeAssignment's job, which ActiveModel::API used to chain for us.
|
|
144
|
+
super()
|
|
145
|
+
assign_attributes(declared) unless declared.empty?
|
|
146
|
+
self.class.attribute_names.each { |name| public_send(name) }
|
|
147
|
+
|
|
148
|
+
@unknown_attributes ||= PortableValue.raw({}, path: "unknown attributes")
|
|
149
|
+
validate_record!
|
|
150
|
+
@data = build_data
|
|
151
|
+
@attributes.freeze
|
|
152
|
+
freeze
|
|
153
|
+
rescue Error
|
|
154
|
+
raise
|
|
155
|
+
rescue ActiveModel::UnknownAttributeError, ArgumentError => error
|
|
156
|
+
raise record_error_class, error.message
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The written projection of the whole payload: JSON primitives, arrays, and
|
|
160
|
+
# string-keyed hashes, including fields a newer producer added that this version
|
|
161
|
+
# does not declare. This is the single form the private queue representation and
|
|
162
|
+
# the public envelope both read, and it is built once per instance rather than
|
|
163
|
+
# once per serialization.
|
|
164
|
+
def data
|
|
165
|
+
@data
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Two records of one class that carry the same payload are the same value. The
|
|
169
|
+
# comparison is over the written projection rather than the cast attributes so
|
|
170
|
+
# that a record reconstructed from a queue matches the one it was written from,
|
|
171
|
+
# and so that unknown preserved fields participate.
|
|
172
|
+
def ==(other)
|
|
173
|
+
other.instance_of?(self.class) && other.data == data
|
|
174
|
+
end
|
|
175
|
+
alias_method :eql?, :==
|
|
176
|
+
|
|
177
|
+
def hash
|
|
178
|
+
[ self.class, data ].hash
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# An immutable value has no meaningful copy, and Active Model's own
|
|
182
|
+
# initialize_dup deep-dups into an unfrozen attribute set while clearing
|
|
183
|
+
# errors, which would otherwise hand back a mutable record whose readers
|
|
184
|
+
# disagree with its payload view.
|
|
185
|
+
def dup
|
|
186
|
+
frozen? ? self : super
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def clone(freeze: nil)
|
|
190
|
+
frozen? ? self : super
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Validations already ran once, during construction. Active Model would
|
|
194
|
+
# re-run them here, which raises on a frozen record on Rails 7.2 and can
|
|
195
|
+
# report a published fact as invalid on later versions when a validation
|
|
196
|
+
# depends on external state.
|
|
197
|
+
def valid?(context = nil)
|
|
198
|
+
frozen? ? true : super
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Object#inspect prints every instance variable, and Active Job logs each
|
|
202
|
+
# argument's inspect by default, so an unmanaged representation publishes
|
|
203
|
+
# domain payload into ordinary application logs.
|
|
204
|
+
def inspect
|
|
205
|
+
"#<#{self.class.name || self.class.inspect}>"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
private
|
|
209
|
+
def attribute_method?(attribute_name)
|
|
210
|
+
self.class.attribute_names.include?(attribute_name)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def normalize_input(attributes, keyword_attributes)
|
|
214
|
+
unless attributes.nil? || attributes.is_a?(Hash)
|
|
215
|
+
raise record_error_class, "attributes must be supplied as a hash or keywords"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
combined = (attributes || {}).merge(keyword_attributes) do |key|
|
|
219
|
+
raise record_error_class, "attribute #{key.inspect} was supplied more than once"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
combined.each_with_object({}) do |(key, value), result|
|
|
223
|
+
unless key.is_a?(String) || key.is_a?(Symbol)
|
|
224
|
+
raise record_error_class, "attribute names must be strings or symbols"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
normalized = key.to_s
|
|
228
|
+
if result.key?(normalized)
|
|
229
|
+
raise record_error_class, "attribute #{normalized.inspect} was supplied more than once"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
result[normalized] = value
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def unknown_attributes_message(unknown)
|
|
237
|
+
"unknown attributes: #{unknown.keys.sort.join(", ")}"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def partition_attributes(input)
|
|
241
|
+
known_names = self.class.attribute_names
|
|
242
|
+
input.partition { |name, _value| known_names.include?(name) }.map(&:to_h)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def validate_record!
|
|
246
|
+
return if valid?
|
|
247
|
+
|
|
248
|
+
error_hash = errors.to_hash(true).transform_values { |messages| messages.map(&:dup).freeze }.freeze
|
|
249
|
+
raise record_error_class.new(record_validation_message, validation_errors: error_hash)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Declared names in declaration order, then preserved unknown fields, so the
|
|
253
|
+
# written form of one logical payload is byte-identical between processes.
|
|
254
|
+
def build_data
|
|
255
|
+
types = self.class.attribute_types
|
|
256
|
+
declared = self.class.attribute_names.to_h do |name|
|
|
257
|
+
[ -name, types[name].serialize(public_send(name)) ]
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
declared.merge(@unknown_attributes).freeze
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def record_error_class
|
|
264
|
+
self.class.record_error_class
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def record_validation_message
|
|
268
|
+
"#{self.class} is invalid: #{errors.full_messages.join(", ")}"
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module EventRail
|
|
2
|
+
module Internal
|
|
3
|
+
# Installs lineage for the duration of a block and restores exactly what was
|
|
4
|
+
# there before.
|
|
5
|
+
#
|
|
6
|
+
# The restore is manual rather than `CurrentAttributes#set`, because the values
|
|
7
|
+
# have to survive nested in-process execution. The Rails executor resets current
|
|
8
|
+
# attributes around `ActiveJob::Base.execute`, but a job performed inside another
|
|
9
|
+
# -- the test adapter, the inline adapter, any `perform_now` -- never passes
|
|
10
|
+
# through the executor, so without an explicit restore the inner job's lineage
|
|
11
|
+
# would still be installed when the outer job resumes.
|
|
12
|
+
module Context
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
FIELDS = [ :message_id, :correlation_id, :causation_id, :originated_at, :extensions ].freeze
|
|
16
|
+
|
|
17
|
+
def establish(message_id:, correlation_id:, causation_id:, originated_at:, extensions:)
|
|
18
|
+
previous = FIELDS.to_h { |field| [ field, Current.public_send(field) ] }
|
|
19
|
+
|
|
20
|
+
Current.message_id = message_id
|
|
21
|
+
Current.correlation_id = correlation_id
|
|
22
|
+
Current.causation_id = causation_id
|
|
23
|
+
Current.originated_at = originated_at
|
|
24
|
+
Current.extensions = extensions
|
|
25
|
+
|
|
26
|
+
yield
|
|
27
|
+
ensure
|
|
28
|
+
previous.each { |field, value| Current.public_send(:"#{field}=", value) }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module EventRail
|
|
2
|
+
module Internal
|
|
3
|
+
class ContractIndex
|
|
4
|
+
def self.build(event_classes)
|
|
5
|
+
index = {}
|
|
6
|
+
|
|
7
|
+
event_classes.each do |event_class|
|
|
8
|
+
unless event_class.is_a?(Class) && event_class < EventRail::Event
|
|
9
|
+
raise InvalidContract, "#{event_class.inspect} is not an EventRail::Event class"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
key = event_class.contract_key
|
|
13
|
+
if index.key?(key)
|
|
14
|
+
raise DuplicateContractError.new(
|
|
15
|
+
event_type: key.first,
|
|
16
|
+
version: key.last,
|
|
17
|
+
event_classes: [ index.fetch(key), event_class ]
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
index[key] = event_class
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
index.freeze
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
module EventRail
|
|
2
|
+
module Internal
|
|
3
|
+
# The private Active Job representation of an event.
|
|
4
|
+
#
|
|
5
|
+
# Private means EventRail owns it entirely: its shape is not public API, no
|
|
6
|
+
# application should read or write it, and it may change under the staging
|
|
7
|
+
# discipline below. The public boundary is `EventRail::Envelope`.
|
|
8
|
+
#
|
|
9
|
+
# Every value written is a JSON primitive, array, or string-keyed hash. That is not
|
|
10
|
+
# a stylistic choice: `ObjectSerializer#serialize` is `@template.merge(hash)` and
|
|
11
|
+
# `ActiveJob::Arguments.serialize` does not recurse into the result, so a `Date`,
|
|
12
|
+
# `Time`, or `BigDecimal` left here would reach the queue adapter raw -- where a
|
|
13
|
+
# JSON-native adapter rejects the job and a JSON column stringifies it and loses
|
|
14
|
+
# precision. The per-type `serialize` contract is what guarantees it, and
|
|
15
|
+
# `event.data` is already the whole payload in that form.
|
|
16
|
+
#
|
|
17
|
+
# FORMAT_VERSION governs the structure of this hash and nothing else. It is
|
|
18
|
+
# deliberately independent of an event's schema version, and it does not govern the
|
|
19
|
+
# canonical encoding of individual values -- that encoding is shared with the public
|
|
20
|
+
# envelope and evolves as a public compatibility contract, not as a private format
|
|
21
|
+
# bump.
|
|
22
|
+
#
|
|
23
|
+
# Evolving it is a staged, read-before-write deployment: a release that reads both
|
|
24
|
+
# the old and the new format ships everywhere first, a later release starts writing
|
|
25
|
+
# the new one, and the old reader is removed only once every queued old-format
|
|
26
|
+
# message is drained or expired.
|
|
27
|
+
class EventSerializer < ActiveJob::Serializers::ObjectSerializer
|
|
28
|
+
FORMAT_VERSION = 1
|
|
29
|
+
SUPPORTED_FORMAT_VERSIONS = [ FORMAT_VERSION ].freeze
|
|
30
|
+
|
|
31
|
+
FORMAT_KEY = "format"
|
|
32
|
+
TYPE_KEY = "event_type"
|
|
33
|
+
VERSION_KEY = "event_version"
|
|
34
|
+
METADATA_KEY = "metadata"
|
|
35
|
+
DATA_KEY = "data"
|
|
36
|
+
|
|
37
|
+
ID = "id"
|
|
38
|
+
SOURCE = "source"
|
|
39
|
+
OCCURRED_AT = "occurred_at"
|
|
40
|
+
CORRELATION_ID = "correlation_id"
|
|
41
|
+
CAUSATION_ID = "causation_id"
|
|
42
|
+
EXTENSIONS = "extensions"
|
|
43
|
+
|
|
44
|
+
# Every Event subclass, so applications register nothing.
|
|
45
|
+
def klass
|
|
46
|
+
EventRail::Event
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Read through methods rather than the constants directly, so a staged release can
|
|
50
|
+
# subclass this to widen what it reads before anything writes the newer form. The
|
|
51
|
+
# staging fixtures rely on that, and so would a real format migration.
|
|
52
|
+
def format_version
|
|
53
|
+
FORMAT_VERSION
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def supported_format_versions
|
|
57
|
+
SUPPORTED_FORMAT_VERSIONS
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def serialize(event)
|
|
61
|
+
unless event.stamped?
|
|
62
|
+
raise InvalidEvent,
|
|
63
|
+
"#{event.class} has not been published, so it cannot be enqueued; pass the event EventRail.publish " \
|
|
64
|
+
"returned rather than the proposal"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
super(
|
|
68
|
+
FORMAT_KEY => format_version,
|
|
69
|
+
TYPE_KEY => event.event_type,
|
|
70
|
+
VERSION_KEY => event.version,
|
|
71
|
+
METADATA_KEY => {
|
|
72
|
+
ID => event.id,
|
|
73
|
+
SOURCE => event.source,
|
|
74
|
+
OCCURRED_AT => Timestamp.written(event.occurred_at),
|
|
75
|
+
CORRELATION_ID => event.correlation_id,
|
|
76
|
+
CAUSATION_ID => event.causation_id,
|
|
77
|
+
EXTENSIONS => event.extensions
|
|
78
|
+
},
|
|
79
|
+
DATA_KEY => event.data
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def deserialize(hash)
|
|
84
|
+
written_format = hash[FORMAT_KEY]
|
|
85
|
+
unless supported_format_versions.include?(written_format)
|
|
86
|
+
raise UnsupportedFormatError.new(
|
|
87
|
+
format_version: written_format, supported_format_versions: supported_format_versions
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
event_type = hash[TYPE_KEY]
|
|
92
|
+
version = hash[VERSION_KEY]
|
|
93
|
+
event_class = resolve!(event_type, version)
|
|
94
|
+
|
|
95
|
+
payload = Notifications.payload_for_representation(hash).merge(format_version: written_format)
|
|
96
|
+
|
|
97
|
+
ActiveSupport::Notifications.instrument("deserialize.event_rail", payload) do
|
|
98
|
+
event_class.send(:__reconstruct__, data: read_data(hash), metadata: read_metadata(hash))
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
# Resolved through the prepared registry, never by constantizing the type name.
|
|
104
|
+
# An unknown type and an unregistered version are different operational problems:
|
|
105
|
+
# the first says this worker does not know the contract at all, the second says it
|
|
106
|
+
# knows it at other versions and a producer is ahead of or behind this deploy.
|
|
107
|
+
def resolve!(event_type, version)
|
|
108
|
+
snapshot = Registry.snapshot
|
|
109
|
+
event_class = snapshot.event_class_for(event_type, version)
|
|
110
|
+
return event_class if event_class
|
|
111
|
+
|
|
112
|
+
known_versions = snapshot.versions_of(event_type)
|
|
113
|
+
if known_versions.empty?
|
|
114
|
+
raise UnknownEventTypeError.new(event_type: event_type, version: version)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
raise UnsupportedEventVersionError.new(
|
|
118
|
+
event_type: event_type, version: version, supported_versions: known_versions
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def read_metadata(hash)
|
|
123
|
+
metadata = hash[METADATA_KEY]
|
|
124
|
+
unless metadata.is_a?(Hash)
|
|
125
|
+
raise SerializationError, "malformed EventRail representation: metadata must be a hash"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
Metadata.complete(
|
|
129
|
+
id: metadata[ID],
|
|
130
|
+
source: metadata[SOURCE],
|
|
131
|
+
occurred_at: metadata[OCCURRED_AT],
|
|
132
|
+
correlation_id: metadata[CORRELATION_ID],
|
|
133
|
+
causation_id: metadata[CAUSATION_ID],
|
|
134
|
+
extensions: metadata[EXTENSIONS] || {}
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def read_data(hash)
|
|
139
|
+
data = hash[DATA_KEY]
|
|
140
|
+
unless data.is_a?(Hash)
|
|
141
|
+
raise SerializationError, "malformed EventRail representation: data must be a hash"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
data
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
require "active_support/isolated_execution_state"
|
|
2
|
+
|
|
3
|
+
module EventRail
|
|
4
|
+
module Internal
|
|
5
|
+
# One publishing execution: a job attempt, or an application boundary block.
|
|
6
|
+
#
|
|
7
|
+
# Publication state belongs to exactly one attempt. It cannot live in
|
|
8
|
+
# `ActiveSupport::CurrentAttributes`, because the Rails executor resets those
|
|
9
|
+
# around `ActiveJob::Base.execute` and nested in-process execution gets no reset,
|
|
10
|
+
# so a subscriber performed inside its publisher -- the test adapter, the inline
|
|
11
|
+
# adapter, any `perform_now` -- would share the publisher's map and could have a
|
|
12
|
+
# legitimate publication rejected as a duplicate in test while succeeding in
|
|
13
|
+
# production.
|
|
14
|
+
#
|
|
15
|
+
# It cannot live in `ActiveSupport::ExecutionContext[:job]` either, which is the
|
|
16
|
+
# mechanism this design originally named. `ActiveJob::Execution#_perform_job`
|
|
17
|
+
# assigns that key and never restores it, so after a nested `perform_now` returns
|
|
18
|
+
# it still points at the inner job: the outer job's next publication would read
|
|
19
|
+
# the inner job's state and derive identity from the inner job's scope. The stack
|
|
20
|
+
# here is pushed and popped by EventRail itself, in an ensure, so a nested
|
|
21
|
+
# execution restores its parent exactly.
|
|
22
|
+
class Execution
|
|
23
|
+
STACK_KEY = :event_rail_executions
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
def stack
|
|
27
|
+
ActiveSupport::IsolatedExecutionState[STACK_KEY] ||= []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def current
|
|
31
|
+
stack.last
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def push(execution)
|
|
35
|
+
stack.push(execution)
|
|
36
|
+
execution
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def pop(execution)
|
|
40
|
+
stack.pop if stack.last.equal?(execution)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def wrap(execution)
|
|
44
|
+
push(execution)
|
|
45
|
+
yield execution
|
|
46
|
+
ensure
|
|
47
|
+
pop(execution)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
attr_reader :job_class, :scope, :started_at
|
|
52
|
+
|
|
53
|
+
# `job_class` is nil for a boundary block. That is what separates the two: a
|
|
54
|
+
# boundary supplies an occurrence time and lineage but derives no identity, so
|
|
55
|
+
# an event published there still receives a random ID, and no duplicate check
|
|
56
|
+
# applies outside a job attempt.
|
|
57
|
+
def initialize(job_class: nil, scope: nil, started_at: nil)
|
|
58
|
+
@job_class = job_class
|
|
59
|
+
@scope = scope
|
|
60
|
+
@started_at = started_at
|
|
61
|
+
@publications = {}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def derives_identity?
|
|
65
|
+
!job_class.nil? && !scope.nil?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def record(key)
|
|
69
|
+
@publications[key]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def record!(key, event:, succeeded:)
|
|
73
|
+
@publications[key] = Record.new(event: event, succeeded: succeeded)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# What one logical publication looked like the last time this attempt tried it.
|
|
77
|
+
# Retaining the failed case is what lets the same attempt retry complete fanout
|
|
78
|
+
# under the identity it already stamped, instead of manufacturing a second one.
|
|
79
|
+
Record = Struct.new(:event, :succeeded, keyword_init: true) do
|
|
80
|
+
def succeeded?
|
|
81
|
+
succeeded
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
module EventRail
|
|
2
|
+
module Internal
|
|
3
|
+
# Extensions are durable baggage, so their bounds are the same wherever they are
|
|
4
|
+
# installed: on an event, on a logical context, or merged from both. One
|
|
5
|
+
# implementation keeps the limits from drifting apart between those doors, and the
|
|
6
|
+
# caller supplies the error class so the message names the thing being built.
|
|
7
|
+
module Extensions
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
EMPTY = {}.freeze
|
|
11
|
+
|
|
12
|
+
def validate!(value, error:)
|
|
13
|
+
return EMPTY if value.nil? || (value.is_a?(Hash) && value.empty?)
|
|
14
|
+
|
|
15
|
+
unless value.is_a?(Hash)
|
|
16
|
+
raise error, "extensions must be a hash of string keys and values"
|
|
17
|
+
end
|
|
18
|
+
if value.length > Limits::MAX_EXTENSION_ENTRIES
|
|
19
|
+
raise error, "extensions exceed #{Limits::MAX_EXTENSION_ENTRIES} entries"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
total_bytes = 0
|
|
23
|
+
result = value.each_with_object({}) do |(key, item), output|
|
|
24
|
+
unless key.is_a?(String) && item.is_a?(String)
|
|
25
|
+
raise error, "extension keys and values must be strings"
|
|
26
|
+
end
|
|
27
|
+
if Limits::RESERVED_EXTENSION_KEYS.include?(key) || key.start_with?("eventrail.")
|
|
28
|
+
raise error, "extension key #{key.inspect} is reserved"
|
|
29
|
+
end
|
|
30
|
+
if key.empty? || !key.valid_encoding? || key.bytesize > Limits::MAX_EXTENSION_KEY_BYTES
|
|
31
|
+
raise error, "extension key #{key.inspect} is invalid or too long"
|
|
32
|
+
end
|
|
33
|
+
if !item.valid_encoding? || item.bytesize > Limits::MAX_EXTENSION_VALUE_BYTES
|
|
34
|
+
raise error, "extension value for #{key.inspect} is invalid or too long"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
total_bytes += key.bytesize + item.bytesize
|
|
38
|
+
output[key.dup.freeze] = item.dup.freeze
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
if total_bytes > Limits::MAX_EXTENSIONS_BYTES
|
|
42
|
+
raise error, "extensions exceed #{Limits::MAX_EXTENSIONS_BYTES} encoded bytes"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
result.freeze
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Repeating a key with the same value is how a nested scope says "still true".
|
|
49
|
+
# Repeating it with a different value is an attempt to rewrite baggage an
|
|
50
|
+
# ancestor installed, which would make the same key mean different things at
|
|
51
|
+
# different depths of one flow.
|
|
52
|
+
def merge!(inherited, added, error:)
|
|
53
|
+
return inherited if added.nil? || added.empty?
|
|
54
|
+
|
|
55
|
+
validated = validate!(added, error: error)
|
|
56
|
+
return validated if inherited.nil? || inherited.empty?
|
|
57
|
+
|
|
58
|
+
conflicts = validated.filter_map do |key, value|
|
|
59
|
+
key if inherited.key?(key) && inherited.fetch(key) != value
|
|
60
|
+
end
|
|
61
|
+
unless conflicts.empty?
|
|
62
|
+
raise error, "extension #{conflicts.sort.join(", ")} already has a different value in this context"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
validate!(inherited.merge(validated), error: error)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|