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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +47 -0
  3. data/CONTRIBUTING.md +14 -0
  4. data/MIT-LICENSE +20 -0
  5. data/README.md +364 -0
  6. data/SECURITY.md +3 -0
  7. data/lib/event_rail/contract.rb +44 -0
  8. data/lib/event_rail/current.rb +95 -0
  9. data/lib/event_rail/data.rb +4 -0
  10. data/lib/event_rail/envelope.rb +123 -0
  11. data/lib/event_rail/errors.rb +196 -0
  12. data/lib/event_rail/event.rb +258 -0
  13. data/lib/event_rail/internal/attribute_record.rb +272 -0
  14. data/lib/event_rail/internal/context.rb +32 -0
  15. data/lib/event_rail/internal/contract_index.rb +28 -0
  16. data/lib/event_rail/internal/event_serializer.rb +148 -0
  17. data/lib/event_rail/internal/execution.rb +86 -0
  18. data/lib/event_rail/internal/extensions.rb +69 -0
  19. data/lib/event_rail/internal/identity.rb +90 -0
  20. data/lib/event_rail/internal/notifications.rb +42 -0
  21. data/lib/event_rail/internal/portable_value.rb +108 -0
  22. data/lib/event_rail/internal/registry.rb +224 -0
  23. data/lib/event_rail/internal/stamping.rb +185 -0
  24. data/lib/event_rail/internal/subscriber_execution.rb +61 -0
  25. data/lib/event_rail/internal/timestamp.rb +85 -0
  26. data/lib/event_rail/internal/transaction.rb +33 -0
  27. data/lib/event_rail/internal/types.rb +441 -0
  28. data/lib/event_rail/job_context.rb +124 -0
  29. data/lib/event_rail/limits.rb +31 -0
  30. data/lib/event_rail/metadata.rb +94 -0
  31. data/lib/event_rail/portable_type.rb +35 -0
  32. data/lib/event_rail/publication.rb +33 -0
  33. data/lib/event_rail/publish.rb +88 -0
  34. data/lib/event_rail/railtie.rb +15 -0
  35. data/lib/event_rail/subscriptions.rb +59 -0
  36. data/lib/event_rail/version.rb +3 -0
  37. data/lib/event_rail.rb +52 -0
  38. metadata +184 -0
@@ -0,0 +1,123 @@
1
+ module EventRail
2
+ # The format-neutral boundary an application's own codec reads and writes.
3
+ #
4
+ # envelope = EventRail::Envelope.of(published.event)
5
+ # payload = MyJsonCodec.encode(envelope) # the application owns the bytes
6
+ #
7
+ # EventRail produces no bytes and mandates no format. It exposes the logical fields --
8
+ # contract, metadata, and the event's portable data projection -- and stops there,
9
+ # because canonical bytes, a universal hash, and a content type are decisions that
10
+ # belong to whoever owns the transport. There is deliberately no `to_h` that could
11
+ # become a de facto wire format.
12
+ #
13
+ # `data` is the portable projection, so a codec never receives a `Date`, a
14
+ # `BigDecimal`, or a nested Ruby object it would have to decide how to canonicalize.
15
+ # Every value is a JSON primitive, array, or string-keyed hash, including fields a
16
+ # newer producer added that the local class does not declare.
17
+ class Envelope
18
+ attr_reader :contract, :metadata, :data
19
+
20
+ def self.of(event)
21
+ unless event.is_a?(Event)
22
+ raise InvalidEnvelope, "#{event.inspect} is not an EventRail::Event"
23
+ end
24
+ unless event.stamped?
25
+ raise InvalidEnvelope, "#{event.class} has not been published, so it has no durable metadata to export"
26
+ end
27
+
28
+ new(contract: Contract.of(event.class), metadata: event.metadata, data: event.data)
29
+ end
30
+
31
+ def initialize(contract:, metadata:, data:)
32
+ unless contract.is_a?(Contract)
33
+ raise InvalidEnvelope, "contract must be an EventRail::Contract; got #{contract.inspect}"
34
+ end
35
+ unless metadata.is_a?(Metadata) && metadata.complete?
36
+ raise InvalidEnvelope, "metadata must be complete EventRail::Metadata; got #{metadata.inspect}"
37
+ end
38
+ unless data.is_a?(Hash) && data.keys.all? { |key| key.is_a?(String) }
39
+ raise InvalidEnvelope, "data must be a string-keyed hash"
40
+ end
41
+
42
+ @contract = contract
43
+ @metadata = metadata
44
+ @data = data.freeze
45
+ freeze
46
+ end
47
+
48
+ def event_type
49
+ contract.event_type
50
+ end
51
+
52
+ def version
53
+ contract.version
54
+ end
55
+
56
+ def id
57
+ metadata.id
58
+ end
59
+
60
+ def source
61
+ metadata.source
62
+ end
63
+
64
+ def occurred_at
65
+ metadata.occurred_at
66
+ end
67
+
68
+ def correlation_id
69
+ metadata.correlation_id
70
+ end
71
+
72
+ def causation_id
73
+ metadata.causation_id
74
+ end
75
+
76
+ def extensions
77
+ metadata.extensions
78
+ end
79
+
80
+ # The only public trusted-reconstruction entry point, and it takes the class
81
+ # explicitly.
82
+ #
83
+ # V1 defines no resolver protocol and never constantizes anything from the
84
+ # envelope: a type name arriving over a network must not be able to name a Ruby
85
+ # constant. An application maps a contract to a class with its own allowlist before
86
+ # calling this:
87
+ #
88
+ # ALLOWED = { [ "orders.order_placed", 1 ] => Orders::OrderPlaced }.freeze
89
+ # event_class = ALLOWED.fetch([ envelope.event_type, envelope.version ])
90
+ # envelope.to_event(event_class)
91
+ #
92
+ # The internal queue registry deliberately does not authorize external input: it
93
+ # exists so a worker can read its own organization's queue, which is a different
94
+ # trust question from accepting a message off a shared bus.
95
+ def to_event(event_class)
96
+ unless event_class.is_a?(Class) && event_class < Event
97
+ raise InvalidEnvelope, "#{event_class.inspect} is not an EventRail::Event class"
98
+ end
99
+ unless event_class.event_type == event_type && event_class.version == version
100
+ raise InvalidEnvelope,
101
+ "#{event_class} declares #{Contract.of(event_class)} but this envelope carries #{contract}"
102
+ end
103
+
104
+ event_class.send(:__reconstruct__, data: data, metadata: metadata)
105
+ end
106
+
107
+ def ==(other)
108
+ other.instance_of?(self.class) && other.contract == contract &&
109
+ other.metadata == metadata && other.data == data
110
+ end
111
+ alias_method :eql?, :==
112
+
113
+ def hash
114
+ [ self.class, contract, metadata, data ].hash
115
+ end
116
+
117
+ # Contract and identity only: an envelope carries domain data, and a diagnostic
118
+ # string is not the place for it.
119
+ def inspect
120
+ "#<EventRail::Envelope #{contract} id=#{id.inspect} source=#{source.inspect}>"
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,196 @@
1
+ module EventRail
2
+ # Every error EventRail raises descends from this, so an application can rescue
3
+ # the library without naming its internals. Each subclass exposes stable
4
+ # diagnostic fields only -- contracts, identifiers, and application classes --
5
+ # never a registry, serializer, job instance, or other internal object, and each
6
+ # preserves the cause it wrapped so the original backtrace stays reachable.
7
+ class Error < StandardError
8
+ end
9
+
10
+ # Something about a class body is wrong, so it is wrong for every instance: a
11
+ # reserved or shadowing attribute name, a callable default, an attribute type with
12
+ # no written form, an invalid subscription.
13
+ class DeclarationError < Error
14
+ end
15
+
16
+ # A value cannot become the declared type without discarding information, or is not
17
+ # portable at all.
18
+ class CastingError < Error
19
+ end
20
+
21
+ class InvalidData < Error
22
+ attr_reader :validation_errors
23
+
24
+ def initialize(message = "nested data is invalid", validation_errors: {})
25
+ @validation_errors = validation_errors.freeze
26
+ super(message)
27
+ end
28
+ end
29
+
30
+ class InvalidEvent < Error
31
+ attr_reader :validation_errors
32
+
33
+ def initialize(message = "event is invalid", validation_errors: {})
34
+ @validation_errors = validation_errors.freeze
35
+ super(message)
36
+ end
37
+ end
38
+
39
+ class InvalidMetadata < Error
40
+ end
41
+
42
+ class InvalidContract < Error
43
+ end
44
+
45
+ class DuplicateContractError < InvalidContract
46
+ attr_reader :event_type, :version, :event_classes
47
+
48
+ def initialize(event_type:, version:, event_classes:)
49
+ @event_type = event_type
50
+ @version = version
51
+ @event_classes = event_classes.freeze
52
+ names = event_classes.map { |event_class| event_class.name || event_class.inspect }
53
+ super("duplicate event contract #{event_type.inspect} version #{version}: #{names.join(", ")}")
54
+ end
55
+ end
56
+
57
+ # A logical context cannot be established: a nested scope tried to replace lineage
58
+ # it inherited, or an identifier or extension is out of bounds.
59
+ class InvalidContext < Error
60
+ end
61
+
62
+ # An external envelope cannot be trusted into a typed event.
63
+ class InvalidEnvelope < Error
64
+ end
65
+
66
+ # Publication ran before the application finished preparing, so the subscriber
67
+ # snapshot does not exist yet. Deliberately distinct from a prepared application
68
+ # whose event happens to have no subscribers.
69
+ class NotReadyError < Error
70
+ end
71
+
72
+ # A subscriber was handed an argument it cannot treat as its declared event.
73
+ class UnexpectedEventError < Error
74
+ attr_reader :job_class, :expected_event_classes, :received_class
75
+
76
+ def initialize(message, job_class:, expected_event_classes:, received_class:)
77
+ @job_class = job_class
78
+ @expected_event_classes = expected_event_classes.freeze
79
+ @received_class = received_class
80
+ super(message)
81
+ end
82
+ end
83
+
84
+ class SerializationError < Error
85
+ end
86
+
87
+ # The representation names a format this release does not read. Raised before any
88
+ # subscriber code runs, and distinct from an event schema version problem: the
89
+ # format version governs the representation's structure, not the event's contract.
90
+ class UnsupportedFormatError < SerializationError
91
+ attr_reader :format_version, :supported_format_versions
92
+
93
+ def initialize(format_version:, supported_format_versions:)
94
+ @format_version = format_version
95
+ @supported_format_versions = supported_format_versions.freeze
96
+ super(
97
+ "unsupported EventRail serialization format #{format_version.inspect}; " \
98
+ "this release reads #{supported_format_versions.join(", ")}"
99
+ )
100
+ end
101
+ end
102
+
103
+ class UnknownEventTypeError < SerializationError
104
+ attr_reader :event_type, :version
105
+
106
+ def initialize(event_type:, version:)
107
+ @event_type = event_type
108
+ @version = version
109
+ super("no registered event class for #{event_type.inspect} version #{version.inspect}")
110
+ end
111
+ end
112
+
113
+ class UnsupportedEventVersionError < SerializationError
114
+ attr_reader :event_type, :version, :supported_versions
115
+
116
+ def initialize(event_type:, version:, supported_versions:)
117
+ @event_type = event_type
118
+ @version = version
119
+ @supported_versions = supported_versions.freeze
120
+ super(
121
+ "event #{event_type.inspect} version #{version.inspect} is not registered; " \
122
+ "registered versions are #{supported_versions.sort.join(", ")}"
123
+ )
124
+ end
125
+ end
126
+
127
+ class PublicationError < Error
128
+ end
129
+
130
+ # The same logical fact was published twice in one execution after the first
131
+ # publication succeeded.
132
+ class DuplicatePublicationError < PublicationError
133
+ attr_reader :event_type, :version, :event_id
134
+
135
+ def initialize(event_type:, version:, event_id:)
136
+ @event_type = event_type
137
+ @version = version
138
+ @event_id = event_id
139
+ super(
140
+ "#{event_type.inspect} version #{version} with this logical identity was already published " \
141
+ "as #{event_id.inspect} in this execution"
142
+ )
143
+ end
144
+ end
145
+
146
+ # A retry resolved to a recorded failed publication whose fact differs from the one
147
+ # now being published. Neither choice is safe -- fanning out the recorded payload
148
+ # discards the new data, and reusing the recorded ID for new data lies to every
149
+ # consumer that deduplicates on it -- so publication refuses instead.
150
+ class RetryPayloadMismatchError < PublicationError
151
+ attr_reader :event_type, :version, :event_id, :differing_fields
152
+
153
+ def initialize(event_type:, version:, event_id:, differing_fields:)
154
+ @event_type = event_type
155
+ @version = version
156
+ @event_id = event_id
157
+ @differing_fields = differing_fields.freeze
158
+ super(
159
+ "retrying publication of #{event_type.inspect} version #{version} as #{event_id.inspect} " \
160
+ "but #{differing_fields.sort.join(", ")} differ from the recorded event"
161
+ )
162
+ end
163
+ end
164
+
165
+ # Publication happened inside an open application database transaction. Both queue
166
+ # deferral settings are wrong there, in opposite directions: a deferred enqueue
167
+ # cannot report its own failure, and an immediate one announces a fact a rollback
168
+ # then contradicts.
169
+ class TransactionalPublicationError < PublicationError
170
+ def initialize(message = nil)
171
+ super(
172
+ message ||
173
+ "cannot publish inside an open database transaction; publish after the transaction commits"
174
+ )
175
+ end
176
+ end
177
+
178
+ # Fanout could not complete. The accepted and skipped lists are what an operator
179
+ # needs to know which subscribers may already have work queued, because publication
180
+ # is at-least-once and the retry will enqueue all of them again.
181
+ class EnqueueError < PublicationError
182
+ attr_reader :event, :accepted_subscribers, :skipped_subscribers, :failed_subscriber
183
+
184
+ def initialize(event:, accepted_subscribers:, skipped_subscribers:, failed_subscriber:, message: nil)
185
+ @event = event
186
+ @accepted_subscribers = accepted_subscribers.freeze
187
+ @skipped_subscribers = skipped_subscribers.freeze
188
+ @failed_subscriber = failed_subscriber
189
+ super(
190
+ message ||
191
+ "could not enqueue #{failed_subscriber} for #{event.event_type.inspect} version #{event.version} " \
192
+ "(#{event.id}); #{accepted_subscribers.length} accepted, #{skipped_subscribers.length} skipped"
193
+ )
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,258 @@
1
+ module EventRail
2
+ class Event < Internal::AttributeRecord
3
+ VALUE_NOT_GIVEN = Object.new.freeze
4
+
5
+ # Metadata an application may supply, but only as an explicit keyword: reaching
6
+ # it through attribute data is how forged lineage would arrive from a params hash.
7
+ KEYWORD_METADATA_NAMES = %w[extensions occurred_at].freeze
8
+
9
+ # Metadata an application may never supply. These are derived during publication
10
+ # or arrive through validated reconstruction.
11
+ DERIVED_METADATA_NAMES = %w[causation_id correlation_id id source].freeze
12
+
13
+ RESERVED_ATTRIBUTE_NAMES = %w[
14
+ attributes
15
+ contract
16
+ correlation_id
17
+ causation_id
18
+ data
19
+ default_source
20
+ errors
21
+ event_type
22
+ extensions
23
+ id
24
+ identity_by
25
+ metadata
26
+ occurred_at
27
+ payload
28
+ source
29
+ stamped?
30
+ type
31
+ valid?
32
+ version
33
+ ].freeze
34
+
35
+ class << self
36
+ def event_type(value = VALUE_NOT_GIVEN)
37
+ return @event_type if value.equal?(VALUE_NOT_GIVEN)
38
+
39
+ unless value.is_a?(String) && !value.empty? && value.valid_encoding?
40
+ raise DeclarationError, "event_type must be a non-empty valid string"
41
+ end
42
+ if value.bytesize > Limits::MAX_EVENT_TYPE_BYTES
43
+ raise DeclarationError, "event_type exceeds #{Limits::MAX_EVENT_TYPE_BYTES} bytes"
44
+ end
45
+
46
+ @event_type = value.dup.freeze
47
+ end
48
+
49
+ def version(value = VALUE_NOT_GIVEN)
50
+ return @event_version if value.equal?(VALUE_NOT_GIVEN)
51
+
52
+ unless value.is_a?(Integer) && value.positive?
53
+ raise DeclarationError, "version must be a positive integer"
54
+ end
55
+
56
+ @event_version = value
57
+ end
58
+
59
+ def default_source(value = VALUE_NOT_GIVEN)
60
+ if value.equal?(VALUE_NOT_GIVEN)
61
+ return @default_source if instance_variable_defined?(:@default_source)
62
+ return superclass.default_source if superclass.respond_to?(:default_source)
63
+
64
+ return
65
+ end
66
+
67
+ Metadata.validate_source!(value)
68
+ @default_source = value.dup.freeze
69
+ rescue InvalidMetadata => error
70
+ raise DeclarationError, error.message
71
+ end
72
+
73
+ def identity_by(*attribute_names)
74
+ return (@identity_attributes || []).dup.freeze if attribute_names.empty?
75
+
76
+ names = attribute_names.map(&:to_s)
77
+ if names.empty? || names.any?(&:empty?) || names.uniq.length != names.length
78
+ raise DeclarationError, "identity_by requires unique non-empty attribute names"
79
+ end
80
+
81
+ @identity_attributes = names.map(&:freeze).freeze
82
+ end
83
+
84
+ # A class that declares either half of the contract is meant to be published and
85
+ # must declare both. One that declares neither is an application's own abstract
86
+ # base -- `class ApplicationEvent < EventRail::Event` -- and is not a registrable
87
+ # contract, so discovery skips it rather than failing preparation over it.
88
+ def concrete?
89
+ !event_type.nil? || !version.nil?
90
+ end
91
+
92
+ def validate_definition!
93
+ unless event_type && version
94
+ raise InvalidContract, "#{self} must explicitly declare event_type and version"
95
+ end
96
+
97
+ identity_by.each do |attribute_name|
98
+ unless attribute_names.include?(attribute_name)
99
+ raise InvalidContract, "#{self} identity attribute #{attribute_name.inspect} is not declared"
100
+ end
101
+ unless attribute_types[attribute_name].is_a?(Internal::Types::Scalar)
102
+ raise InvalidContract, "#{self} identity attribute #{attribute_name.inspect} must be scalar"
103
+ end
104
+ end
105
+
106
+ true
107
+ end
108
+
109
+ def contract_key
110
+ validate_definition!
111
+ [ event_type, version ].freeze
112
+ end
113
+
114
+ def reserved_attribute_names
115
+ (super + RESERVED_ATTRIBUTE_NAMES).uniq.freeze
116
+ end
117
+
118
+ def record_error_class
119
+ InvalidEvent
120
+ end
121
+
122
+ private
123
+ # Not public API: reconstruction of a trusted representation belongs to the
124
+ # private queue serializer and to validated envelope reconstruction, which
125
+ # supply metadata they have already checked. An application that could call
126
+ # this could install any lineage it liked.
127
+ def __reconstruct__(data:, metadata:)
128
+ validate_definition!
129
+ unless metadata.is_a?(Metadata) && metadata.complete?
130
+ raise InvalidMetadata, "trusted reconstruction requires complete metadata"
131
+ end
132
+
133
+ __event_rail_reconstruct__(data, state: { :@metadata => metadata })
134
+ end
135
+ end
136
+
137
+ attr_reader :metadata
138
+
139
+ def initialize(attributes = nil, occurred_at: nil, extensions: {}, **payload)
140
+ self.class.validate_definition!
141
+
142
+ # Trusted reconstruction installs complete metadata before initialize runs.
143
+ @metadata ||= Metadata.proposed(occurred_at: occurred_at, extensions: extensions)
144
+ super(attributes, **payload)
145
+
146
+ validate_local_identity! unless @metadata.complete?
147
+ end
148
+
149
+ def event_type
150
+ self.class.event_type
151
+ end
152
+
153
+ def version
154
+ self.class.version
155
+ end
156
+
157
+ def id
158
+ metadata.id
159
+ end
160
+
161
+ def source
162
+ metadata.source
163
+ end
164
+
165
+ def occurred_at
166
+ metadata.occurred_at
167
+ end
168
+
169
+ def correlation_id
170
+ metadata.correlation_id
171
+ end
172
+
173
+ def causation_id
174
+ metadata.causation_id
175
+ end
176
+
177
+ def extensions
178
+ metadata.extensions
179
+ end
180
+
181
+ def stamped?
182
+ metadata.complete?
183
+ end
184
+
185
+ # Metadata participates, so a proposal and the stamped event copied from it are
186
+ # different values: one is a fact with an identity and the other is a request to
187
+ # record one.
188
+ def ==(other)
189
+ super && other.metadata == metadata
190
+ end
191
+ alias_method :eql?, :==
192
+
193
+ def hash
194
+ [ self.class, data, metadata ].hash
195
+ end
196
+
197
+ # Contract and metadata only: domain payload and extensions stay out of
198
+ # diagnostics, including Active Job argument logging.
199
+ def inspect
200
+ "#<#{self.class.name || self.class.inspect} type=#{event_type.inspect} version=#{version.inspect} " \
201
+ "id=#{id.inspect} source=#{source.inspect} occurred_at=#{occurred_at.inspect}>"
202
+ end
203
+
204
+ private
205
+ # Not public API: stamping installs the identity and lineage that make an event
206
+ # a published fact, and publication is the only thing entitled to do that.
207
+ def __stamp__(id:, source: nil, occurred_at: nil, correlation_id:, causation_id: nil, extensions: nil)
208
+ resolved_source = source || self.class.default_source
209
+ raise InvalidMetadata, "source is required to stamp #{self.class}" unless resolved_source
210
+
211
+ stamped_metadata = Metadata.complete(
212
+ id: id,
213
+ source: resolved_source,
214
+ occurred_at: occurred_at || metadata.occurred_at,
215
+ correlation_id: correlation_id,
216
+ causation_id: causation_id,
217
+ extensions: extensions || metadata.extensions
218
+ )
219
+
220
+ # A metadata-only copy. Reconstructing through the validating constructor
221
+ # would re-cast and re-validate payload this instance already canonicalized,
222
+ # and would rebuild every nested data object, for no change to the payload.
223
+ self.class.send(:__event_rail_copy__, self, state: { :@metadata => stamped_metadata })
224
+ end
225
+
226
+ # A declared identity attribute that is nil cannot produce a canonical value, so
227
+ # the event can never be published. The local constructor is the strict door and
228
+ # the only place whose backtrace points at the code that left the field empty.
229
+ # Trusted reconstruction stays permissive: an external event of this contract may
230
+ # legitimately omit a field, and it arrives with an identity already assigned.
231
+ def validate_local_identity!
232
+ missing = self.class.identity_by.select { |name| public_send(name).nil? }
233
+ return if missing.empty?
234
+
235
+ raise InvalidEvent,
236
+ "#{self.class} declares #{missing.sort.join(", ")} as logical identity, so it cannot be nil"
237
+ end
238
+
239
+ def unknown_attributes_message(unknown)
240
+ names = unknown.keys
241
+ keyword_only = names & KEYWORD_METADATA_NAMES
242
+ derived = names & DERIVED_METADATA_NAMES
243
+ return super if keyword_only.empty? && derived.empty?
244
+
245
+ parts = []
246
+ unless keyword_only.empty?
247
+ parts << "#{keyword_only.sort.join(", ")} must be supplied as a keyword argument, not as attribute data"
248
+ end
249
+ unless derived.empty?
250
+ parts << "#{derived.sort.join(", ")} cannot be set locally; event metadata is derived during " \
251
+ "publication or supplied through validated reconstruction"
252
+ end
253
+ remaining = names - keyword_only - derived
254
+ parts << "unknown attributes: #{remaining.sort.join(", ")}" unless remaining.empty?
255
+ parts.join("; ")
256
+ end
257
+ end
258
+ end