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,90 @@
1
+ require "active_support/core_ext/digest/uuid"
2
+ require "bigdecimal"
3
+ require "date"
4
+
5
+ module EventRail
6
+ module Internal
7
+ # Retry-stable publication identity.
8
+ #
9
+ # The whole point is that the same logical fact, published again by the same
10
+ # execution, derives the same event ID -- so a consumer can collapse the
11
+ # duplicates at-least-once delivery guarantees it will see. That makes both the
12
+ # namespace and the encoding permanent compatibility state: changing either
13
+ # silently gives every future publication a different ID for an unchanged fact,
14
+ # which is why the golden vectors are asserted rather than merely computed.
15
+ module Identity
16
+ module_function
17
+
18
+ # Derived from the URL namespace rather than written as a literal, so the input
19
+ # that produced it stays visible and a typo cannot masquerade as a deliberate
20
+ # value. The golden-vector test pins the result.
21
+ NAMESPACE = Digest::UUID.uuid_v5(
22
+ Digest::UUID::URL_NAMESPACE, "https://github.com/event_rail/event_rail/identity/v1"
23
+ ).freeze
24
+
25
+ # The first publication of an event type in one execution needs no key: there is
26
+ # nothing to distinguish it from.
27
+ SINGLETON = :__event_rail_singleton__
28
+
29
+ def derive(source:, job_class:, scope:, event_type:, version:, logical_identity:)
30
+ Digest::UUID.uuid_v5(
31
+ NAMESPACE,
32
+ encode([ source, job_class, scope, event_type, version, logical_identity ])
33
+ )
34
+ end
35
+
36
+ # Length-delimited and type-tagged. Ruby's own Hash#hash is per-process, inspect
37
+ # is not a format, and a plain join makes ["ab", "c"] and ["a", "bc"] the same
38
+ # string. A tag also keeps 1 and "1" apart, so a call site that passes an integer
39
+ # key cannot collide with one that passes its decimal spelling.
40
+ def encode(components)
41
+ components.map { |component| encode_component(component) }.join
42
+ end
43
+
44
+ def encode_component(value)
45
+ tag, bytes = case value
46
+ when SINGLETON then [ "*", "" ]
47
+ when String then [ "s", value.b ]
48
+ when true then [ "b", "true" ]
49
+ when false then [ "b", "false" ]
50
+ when Integer then [ "i", value.to_s ]
51
+ when BigDecimal
52
+ reject!(value, "a finite number") unless value.finite?
53
+ [ "d", value.to_s("F") ]
54
+ when Float
55
+ reject!(value, "a finite number") unless value.finite?
56
+ [ "f", format("%.17g", value) ]
57
+ when Time then [ "T", Timestamp.written(value) ]
58
+ when DateTime
59
+ reject!(value, "a Time rather than a DateTime")
60
+ when Date then [ "D", value.iso8601 ]
61
+ when Array
62
+ [ "L", "#{value.length}:#{encode(value.map { |item| encode_scalar!(item) })}" ]
63
+ else
64
+ reject!(value, "a non-null scalar")
65
+ end
66
+
67
+ "#{tag}#{bytes.bytesize}:#{bytes}"
68
+ end
69
+
70
+ # An identity component that is a structure cannot be canonicalized without
71
+ # choosing an ordering and a separator that would then be permanent, and one
72
+ # that is null cannot identify anything.
73
+ def encode_scalar!(value)
74
+ case value
75
+ when String, Integer, Float, BigDecimal, Time, true, false then value
76
+ when DateTime then reject!(value, "a Time rather than a DateTime")
77
+ when Date then value
78
+ else reject!(value, "a non-null scalar")
79
+ end
80
+ end
81
+ private_class_method :encode_scalar!
82
+
83
+ def reject!(value, expectation)
84
+ raise PublicationError,
85
+ "cannot derive publication identity from #{value.inspect}; every component must be #{expectation}"
86
+ end
87
+ private_class_method :reject!
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,42 @@
1
+ require "active_support/notifications"
2
+
3
+ module EventRail
4
+ module Internal
5
+ # Payloads for the four public notifications.
6
+ #
7
+ # Contract, identity, and lineage only. Domain data and extensions are excluded by
8
+ # construction rather than by filtering, because a notification payload reaches logs
9
+ # and APM by default and a fact's contents are the application's to decide about.
10
+ # Every call site uses the block form, so Rails' own :exception keys report failures
11
+ # and EventRail needs no separate failure event.
12
+ module Notifications
13
+ module_function
14
+
15
+ # The same keys, read from the private queue representation, for the one
16
+ # notification that fires before a typed event exists.
17
+ def payload_for_representation(hash)
18
+ metadata = hash["metadata"] || {}
19
+
20
+ {
21
+ event_type: hash["event_type"],
22
+ event_version: hash["event_version"],
23
+ event_id: metadata["id"],
24
+ source: metadata["source"],
25
+ correlation_id: metadata["correlation_id"],
26
+ causation_id: metadata["causation_id"]
27
+ }
28
+ end
29
+
30
+ def payload_for(event)
31
+ {
32
+ event_type: event.event_type,
33
+ event_version: event.version,
34
+ event_id: event.id,
35
+ source: event.source,
36
+ correlation_id: event.correlation_id,
37
+ causation_id: event.causation_id
38
+ }
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,108 @@
1
+ require "bigdecimal"
2
+ require "date"
3
+
4
+ module EventRail
5
+ module Internal
6
+ module PortableValue
7
+ module_function
8
+
9
+ RAW_SCALARS = [ NilClass, TrueClass, FalseClass, String, Integer, Float ].freeze
10
+ TYPED_SCALARS = [ BigDecimal, Date, DateTime, Time ].freeze
11
+
12
+ def raw(value, path: "value", depth: 0)
13
+ reject_record!(value, path: path)
14
+
15
+ case value
16
+ when *RAW_SCALARS
17
+ freeze_scalar(value, path: path)
18
+ when Array
19
+ check_depth!(depth, path: path)
20
+ value.each_with_index.map do |item, index|
21
+ raw(item, path: "#{path}[#{index}]", depth: depth + 1)
22
+ end.freeze
23
+ when Hash
24
+ check_depth!(depth, path: path)
25
+ value.each_with_object({}) do |(key, item), result|
26
+ unless key.is_a?(String)
27
+ raise CastingError, "#{path} must use string hash keys; got #{key.inspect}"
28
+ end
29
+ if key.start_with?(Limits::ACTIVE_JOB_RESERVED_KEY_PREFIX)
30
+ raise CastingError,
31
+ "#{path} key #{key.inspect} uses the #{Limits::ACTIVE_JOB_RESERVED_KEY_PREFIX.inspect} " \
32
+ "prefix Active Job reserves in its argument encoding"
33
+ end
34
+
35
+ frozen_key = key.dup.freeze
36
+ result[frozen_key] = raw(item, path: "#{path}.#{key}", depth: depth + 1)
37
+ end.freeze
38
+ else
39
+ raise CastingError, "#{path} contains unsupported #{value.class}"
40
+ end
41
+ end
42
+
43
+ def typed(value, path: "value")
44
+ reject_record!(value, path: path)
45
+
46
+ case value
47
+ when *RAW_SCALARS, *TYPED_SCALARS
48
+ freeze_scalar(value, path: path)
49
+ when Array, Hash
50
+ raw(value, path: path)
51
+ else
52
+ raise CastingError, "#{path} cast to unsupported #{value.class}"
53
+ end
54
+ end
55
+
56
+ # A written value has crossed no boundary yet, so this is a structural check
57
+ # of what a JSON encoder can carry rather than a trust decision.
58
+ def json_primitive?(value)
59
+ case value
60
+ when *RAW_SCALARS
61
+ !value.is_a?(Float) || value.finite?
62
+ when Array
63
+ value.all? { |item| json_primitive?(item) }
64
+ when Hash
65
+ value.all? { |key, item| key.is_a?(String) && json_primitive?(item) }
66
+ else
67
+ false
68
+ end
69
+ end
70
+
71
+ def literal_default?(value)
72
+ case value
73
+ when Proc, Method
74
+ false
75
+ when Array
76
+ value.all? { |item| literal_default?(item) }
77
+ when Hash
78
+ value.all? { |key, item| literal_default?(key) && literal_default?(item) }
79
+ else
80
+ !value.respond_to?(:call)
81
+ end
82
+ end
83
+
84
+ def reject_record!(value, path: "value")
85
+ global_id_value = value.class.name == "GlobalID" && value.respond_to?(:app) && value.respond_to?(:model_id)
86
+ return unless value.respond_to?(:to_global_id) || global_id_value
87
+
88
+ raise CastingError, "#{path} cannot contain records or GlobalID values"
89
+ end
90
+
91
+ def check_depth!(depth, path:)
92
+ return if depth < Limits::MAX_RAW_DEPTH
93
+
94
+ raise CastingError, "#{path} exceeds the maximum nesting depth of #{Limits::MAX_RAW_DEPTH}"
95
+ end
96
+ private_class_method :check_depth!
97
+
98
+ def freeze_scalar(value, path:)
99
+ if value.is_a?(Numeric) && value.respond_to?(:finite?) && !value.finite?
100
+ raise CastingError, "#{path} must contain a finite number"
101
+ end
102
+
103
+ value.is_a?(String) ? value.dup.freeze : value.freeze
104
+ end
105
+ private_class_method :freeze_scalar
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,224 @@
1
+ require "active_support/core_ext/class/subclasses"
2
+ require "monitor"
3
+
4
+ module EventRail
5
+ module Internal
6
+ # The subscriber registry: a pending list the macro appends to, and an immutable
7
+ # snapshot preparation seals from it.
8
+ #
9
+ # Publication reads the snapshot without taking the lock, so a concurrent rebuild
10
+ # can only ever hand a reader a complete old snapshot or a complete new one. The
11
+ # unprepared state is distinct from a prepared snapshot in which an event happens
12
+ # to have no subscribers: the first is a boot-order bug and raises, the second is a
13
+ # legitimate zero-delivery publication.
14
+ class Registry
15
+ CONVENTIONAL_ROOTS = %w[app/events app/jobs].freeze
16
+
17
+ Snapshot = Struct.new(:subscribers, :contracts, keyword_init: true) do
18
+ def subscribers_for(event_class)
19
+ subscribers[event_class] || EMPTY_SUBSCRIBERS
20
+ end
21
+
22
+ def event_class_for(event_type, version)
23
+ contracts[[ event_type, version ]]
24
+ end
25
+
26
+ def versions_of(event_type)
27
+ contracts.keys.filter_map { |type, version| version if type == event_type }
28
+ end
29
+ end
30
+
31
+ EMPTY_SUBSCRIBERS = [].freeze
32
+
33
+ @monitor = Monitor.new
34
+ @pending = []
35
+ @snapshot = nil
36
+ @building = false
37
+
38
+ class << self
39
+ # Appending is idempotent per job class: the macro may be called more than once
40
+ # in one body, and the declarations themselves live on the job class.
41
+ def declare(job_class)
42
+ @monitor.synchronize do
43
+ if @snapshot && !@building
44
+ raise DeclarationError,
45
+ "#{job_class} declared a subscription after EventRail finished preparing, so it would receive no " \
46
+ "deliveries. Move it under a conventional app/events or app/jobs root, or load it from an " \
47
+ "autoload-once path or plain require before application preparation."
48
+ end
49
+
50
+ @pending << job_class unless @pending.include?(job_class)
51
+ end
52
+ end
53
+
54
+ def snapshot
55
+ snapshot = @snapshot
56
+ return snapshot if snapshot
57
+
58
+ raise NotReadyError,
59
+ "EventRail has not prepared its subscriber registry yet; publication is only available after " \
60
+ "application preparation"
61
+ end
62
+
63
+ def prepared?
64
+ !@snapshot.nil?
65
+ end
66
+
67
+ def subscribers_for(event_class)
68
+ snapshot.subscribers_for(event_class)
69
+ end
70
+
71
+ # Reentrant, because eager loading a conventional root runs macros that call
72
+ # back into `declare`.
73
+ def prepare
74
+ @monitor.synchronize do
75
+ previously_building = @building
76
+ @building = true
77
+ begin
78
+ eager_load_conventional_roots
79
+ prune_stale_declarations
80
+ @snapshot = build_snapshot
81
+ ensure
82
+ @building = previously_building
83
+ end
84
+ end
85
+ @snapshot
86
+ end
87
+
88
+ # Internal: opens the pending list for declarations outside preparation. Used by
89
+ # preparation itself and by EventRail's own tests, which define subscriber
90
+ # fixtures after the host application has already been prepared.
91
+ def reopen
92
+ @monitor.synchronize do
93
+ previously_building = @building
94
+ @building = true
95
+ begin
96
+ yield
97
+ ensure
98
+ @building = previously_building
99
+ end
100
+ end
101
+ end
102
+
103
+ def reset!
104
+ @monitor.synchronize do
105
+ @pending = []
106
+ @snapshot = nil
107
+ @building = false
108
+ end
109
+ end
110
+
111
+ private
112
+ # Rails exposes concrete loader roots for the host and every engine through the
113
+ # main autoloader. Iterating `Rails::Engine.subclasses[*].paths["app/jobs"]`
114
+ # does not work: that path is not exposed that way.
115
+ def eager_load_conventional_roots
116
+ return unless defined?(Rails) && Rails.respond_to?(:autoloaders)
117
+
118
+ loader = Rails.autoloaders.main
119
+ return unless loader.respond_to?(:eager_load_dir)
120
+
121
+ loader.dirs.each do |dir|
122
+ next unless CONVENTIONAL_ROOTS.any? { |root| dir.end_with?("/#{root}") }
123
+ next unless Dir.exist?(dir)
124
+
125
+ begin
126
+ loader.eager_load_dir(dir)
127
+ rescue Zeitwerk::Error
128
+ # An ignored or unmanaged directory is a legitimate application choice,
129
+ # not a reason to fail preparation.
130
+ nil
131
+ end
132
+ end
133
+ end
134
+
135
+ # A reload replaces class objects while leaving the previous ones reachable
136
+ # from this list. An entry survives only if the constant its own name denotes
137
+ # is still this exact object, which is what distinguishes a live class from a
138
+ # reloaded class's discarded predecessor. The list is never cleared wholesale,
139
+ # because a declaration in non-reloadable code -- an autoload-once path, or
140
+ # plainly required lib code -- ran its macro once at require time and would be
141
+ # lost on the first rebuild.
142
+ def prune_stale_declarations
143
+ @pending.select! { |job_class| live?(job_class) }
144
+ end
145
+
146
+ def live?(klass)
147
+ name = klass.name
148
+ return false if name.nil?
149
+
150
+ resolved = begin
151
+ Object.const_get(name)
152
+ rescue NameError
153
+ nil
154
+ end
155
+
156
+ resolved.equal?(klass)
157
+ end
158
+
159
+ def build_snapshot
160
+ contracts = build_contracts
161
+ subscribers = {}
162
+
163
+ @pending.each do |job_class|
164
+ validate_subscriber!(job_class)
165
+
166
+ job_class.event_rail_subscriptions.each do |event_class|
167
+ next unless live?(event_class)
168
+
169
+ (subscribers[event_class] ||= []) << job_class
170
+ end
171
+ end
172
+
173
+ subscribers.each_value { |jobs| jobs.sort_by!(&:name) }
174
+ subscribers.transform_values!(&:freeze)
175
+
176
+ Snapshot.new(subscribers: subscribers.freeze, contracts: contracts).freeze
177
+ end
178
+
179
+ # Every discovered event class is validated here rather than at first
180
+ # construction, so a broken contract or identity declaration fails the boot
181
+ # that introduced it instead of the first publication that happens to hit it.
182
+ def build_contracts
183
+ discovered = EventRail::Event.descendants.select { |event_class| live?(event_class) }
184
+ concrete = discovered.select { |event_class| event_class.concrete? }
185
+
186
+ ContractIndex.build(concrete)
187
+ end
188
+
189
+ def validate_subscriber!(job_class)
190
+ unless job_class.instance_methods(false).include?(:perform) ||
191
+ job_class.private_instance_methods(false).include?(:perform)
192
+ raise DeclarationError,
193
+ "#{job_class} declares a subscription but does not define its own perform, so it is abstract"
194
+ end
195
+ unless job_class.subclasses.empty?
196
+ raise DeclarationError,
197
+ "#{job_class} declares a subscription and has subclasses " \
198
+ "(#{job_class.subclasses.map(&:to_s).sort.join(", ")}), so it is abstract; declare the " \
199
+ "subscription on each concrete job instead"
200
+ end
201
+ unless job_class.include?(EventRail::JobContext)
202
+ raise DeclarationError,
203
+ "#{job_class} declares a subscription but does not propagate logical context. Add " \
204
+ "`include EventRail::JobContext` to #{job_class} or to its base class."
205
+ end
206
+
207
+ validate_perform_arity!(job_class)
208
+ end
209
+
210
+ # Exactly one required positional parameter. A splat or a keyword signature
211
+ # would accept an event by accident and make the delivery contract depend on
212
+ # how the method happens to be written.
213
+ def validate_perform_arity!(job_class)
214
+ parameters = job_class.instance_method(:perform).parameters
215
+ unless parameters.map(&:first) == [ :req ]
216
+ raise DeclarationError,
217
+ "#{job_class}#perform must take exactly one required positional event parameter, " \
218
+ "not #{parameters.inspect}"
219
+ end
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,185 @@
1
+ require "securerandom"
2
+
3
+ module EventRail
4
+ module Internal
5
+ # Turns a proposal into a stamped fact: resolves source, selects logical identity,
6
+ # derives a retry-stable ID, fixes occurrence time, and installs lineage. Fanout
7
+ # is a separate concern layered on top of this.
8
+ module Stamping
9
+ module_function
10
+
11
+ Prepared = Struct.new(:event, :execution, :logical_key, :relayed, keyword_init: true) do
12
+ def relayed?
13
+ relayed
14
+ end
15
+ end
16
+
17
+ def prepare(event, key: nil)
18
+ unless event.is_a?(Event)
19
+ raise InvalidEvent, "#{event.inspect} is not an EventRail::Event"
20
+ end
21
+
22
+ return relay(event, key: key) if event.stamped?
23
+
24
+ validate_key!(key)
25
+ execution = Execution.current
26
+ logical_identity = resolve_logical_identity(event, key)
27
+ logical_key = [ event.event_type, event.version, Identity.encode_component(logical_identity) ].freeze
28
+
29
+ recorded = execution&.derives_identity? ? execution.record(logical_key) : nil
30
+ if recorded&.succeeded?
31
+ raise DuplicatePublicationError.new(
32
+ event_type: event.event_type, version: event.version, event_id: recorded.event.id
33
+ )
34
+ end
35
+
36
+ extensions = Extensions.merge!(Current.extensions, event.extensions, error: InvalidEvent)
37
+ detect_retry_mismatch!(event, recorded, extensions) if recorded
38
+
39
+ source = event.class.default_source
40
+ event_id = recorded&.event&.id || derive_id(event, execution, source, logical_identity)
41
+ occurred_at = event.occurred_at || recorded&.event&.occurred_at || default_occurred_at(execution)
42
+
43
+ stamped = event.send(
44
+ :__stamp__,
45
+ id: event_id,
46
+ source: source,
47
+ occurred_at: occurred_at,
48
+ correlation_id: Current.correlation_id || event_id,
49
+ causation_id: Current.message_id,
50
+ extensions: extensions
51
+ )
52
+
53
+ execution.record!(logical_key, event: stamped, succeeded: false) if execution&.derives_identity?
54
+
55
+ Prepared.new(event: stamped, execution: execution, logical_key: logical_key, relayed: false)
56
+ end
57
+
58
+ def succeeded!(prepared)
59
+ execution = prepared.execution
60
+ return unless execution&.derives_identity?
61
+
62
+ execution.record!(prepared.logical_key, event: prepared.event, succeeded: true)
63
+ end
64
+
65
+ # A relayed fact belongs to its origin. Its ID, source, occurrence time,
66
+ # correlation, and extensions are preserved, and the relaying application's own
67
+ # context extensions are deliberately not merged in: local baggage is not part of
68
+ # somebody else's event. Only a missing causation is filled, because the local
69
+ # message genuinely is what caused this relay.
70
+ def relay(event, key:)
71
+ unless key.nil?
72
+ raise PublicationError,
73
+ "#{event.event_type.inspect} already carries the event ID #{event.id.inspect}, so a publication " \
74
+ "key cannot apply to it"
75
+ end
76
+
77
+ execution = Execution.current
78
+ logical_key = [ event.event_type, event.version, Identity.encode_component(event.id) ].freeze
79
+
80
+ if execution&.derives_identity?
81
+ recorded = execution.record(logical_key)
82
+ if recorded&.succeeded?
83
+ raise DuplicatePublicationError.new(
84
+ event_type: event.event_type, version: event.version, event_id: event.id
85
+ )
86
+ end
87
+ end
88
+
89
+ relayed = if event.causation_id.nil? && Current.message_id
90
+ event.send(
91
+ :__stamp__,
92
+ id: event.id,
93
+ source: event.source,
94
+ occurred_at: event.occurred_at,
95
+ correlation_id: event.correlation_id,
96
+ causation_id: Current.message_id,
97
+ extensions: event.extensions
98
+ )
99
+ else
100
+ event
101
+ end
102
+
103
+ execution.record!(logical_key, event: relayed, succeeded: false) if execution&.derives_identity?
104
+
105
+ Prepared.new(event: relayed, execution: execution, logical_key: logical_key, relayed: true)
106
+ end
107
+ private_class_method :relay
108
+
109
+ # 1. An explicit event ID wins, which is the relay path above.
110
+ # 2. An explicit call-site key.
111
+ # 3. The event class's declared identity attributes.
112
+ # 4. A singleton marker, for the first publication of that type in an execution.
113
+ def resolve_logical_identity(event, key)
114
+ return key if key
115
+
116
+ declared = event.class.identity_by
117
+ return Identity::SINGLETON if declared.empty?
118
+
119
+ declared.map { |name| event.public_send(name) }
120
+ end
121
+ private_class_method :resolve_logical_identity
122
+
123
+ # A key spelled as an integer at one call site and as its decimal string at
124
+ # another would derive two identities for one fact, and nothing would report it.
125
+ def validate_key!(key)
126
+ return if key.nil?
127
+
128
+ unless key.is_a?(String) && !key.empty? && key.valid_encoding?
129
+ raise PublicationError, "publication key must be a non-empty string; got #{key.inspect}"
130
+ end
131
+ if key.bytesize > Limits::MAX_IDENTIFIER_BYTES
132
+ raise PublicationError, "publication key exceeds #{Limits::MAX_IDENTIFIER_BYTES} bytes"
133
+ end
134
+ end
135
+ private_class_method :validate_key!
136
+
137
+ def derive_id(event, execution, source, logical_identity)
138
+ return SecureRandom.uuid.freeze unless execution&.derives_identity?
139
+
140
+ unless source
141
+ raise InvalidMetadata, "source is required to publish #{event.class}"
142
+ end
143
+
144
+ Identity.derive(
145
+ source: source,
146
+ job_class: execution.job_class,
147
+ scope: execution.scope,
148
+ event_type: event.event_type,
149
+ version: event.version,
150
+ logical_identity: logical_identity
151
+ ).freeze
152
+ end
153
+ private_class_method :derive_id
154
+
155
+ # The logical publication time: this execution's start, so it is stable across
156
+ # the attempt's own retries, and never inherited from a cause, so a follow-up
157
+ # event is never timestamped earlier than the work that produced it.
158
+ def default_occurred_at(execution)
159
+ execution&.started_at || Timestamp.normalize(Time.now.utc, field: "occurred_at")
160
+ end
161
+ private_class_method :default_occurred_at
162
+
163
+ # Retrying a failed publication republishes the same fact, not merely the same
164
+ # identity. Choosing silently between the recorded payload and the new one would
165
+ # either discard data or hand new data an ID consumers already deduplicated on.
166
+ def detect_retry_mismatch!(event, recorded, extensions)
167
+ differing = []
168
+ differing << "payload" unless event.data == recorded.event.data
169
+ if event.occurred_at && event.occurred_at != recorded.event.occurred_at
170
+ differing << "occurred_at"
171
+ end
172
+ differing << "extensions" unless extensions == recorded.event.extensions
173
+ return if differing.empty?
174
+
175
+ raise RetryPayloadMismatchError.new(
176
+ event_type: event.event_type,
177
+ version: event.version,
178
+ event_id: recorded.event.id,
179
+ differing_fields: differing
180
+ )
181
+ end
182
+ private_class_method :detect_retry_mismatch!
183
+ end
184
+ end
185
+ end