event_rail 0.3.0 → 0.5.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.
@@ -1,38 +1,56 @@
1
1
  require "active_support/core_ext/digest/uuid"
2
+ require "active_support/core_ext/object/blank"
2
3
  require "bigdecimal"
3
4
  require "date"
4
5
 
5
6
  module EventRail
6
7
  module Internal
7
- # Retry-stable publication identity.
8
+ # Publication identity: the ID an event derives, so that the same fact published
9
+ # again derives the same ID and a consumer can collapse the duplicates at-least-once
10
+ # delivery guarantees it will see.
8
11
  #
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.
12
+ # Two derivations, told apart by a leading tag so their inputs never coincide:
13
+ #
14
+ # fact ["fact", source, event_type, identity_list]
15
+ # for an event with declared identity. Nothing about who published it,
16
+ # when, or in which schema version: one fact, one ID, everywhere.
17
+ # execution ["execution", source, job_class, scope, event_type, version, SINGLETON]
18
+ # for an event without declared identity, published inside a job. Stable
19
+ # across that job's retries and across redeliveries of the event a
20
+ # subscriber handles, and nothing more.
21
+ #
22
+ # The namespace, the tags, the component order and the byte encoding are permanent
23
+ # compatibility state, documented in the README with vectors other implementations
24
+ # can reproduce; changing any of them silently gives every future publication a
25
+ # different ID for an unchanged fact, which is why the vectors are asserted rather
26
+ # than merely computed.
15
27
  module Identity
16
28
  module_function
17
29
 
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
30
+ # A literal, owned by this project and named nowhere else, so no one else's
31
+ # derivation can land in the same space.
32
+ NAMESPACE = "21fedac0-42c6-443f-b01c-6980aab52f32".freeze
24
33
 
25
- # The first publication of an event type in one execution needs no key: there is
26
- # nothing to distinguish it from.
34
+ # Closes the execution rule's name: an undeclared event is identified by the
35
+ # execution and its type alone, which is why one execution publishes it once.
27
36
  SINGLETON = :__event_rail_singleton__
28
37
 
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
- )
38
+ # `identity` is always a list: the declared attributes' values in declaration
39
+ # order, or one explicit publication identity. So an explicit "a" and a single
40
+ # declared attribute whose value is "a" name the same fact.
41
+ def fact(source:, event_type:, identity:)
42
+ raise ArgumentError, "fact identity must be a list; got #{identity.inspect}" unless identity.is_a?(Array)
43
+
44
+ uuid_v5(encode([ "fact", source, event_type, identity ]))
45
+ end
46
+
47
+ def execution(source:, job_class:, scope:, event_type:, version:)
48
+ uuid_v5(encode([ "execution", source, job_class, scope, event_type, version, SINGLETON ]))
34
49
  end
35
50
 
51
+ def uuid_v5(name) = Digest::UUID.uuid_v5(NAMESPACE, name)
52
+ private_class_method :uuid_v5
53
+
36
54
  # Length-delimited and type-tagged. Ruby's own Hash#hash is per-process, inspect
37
55
  # is not a format, and a plain join makes ["ab", "c"] and ["a", "bc"] the same
38
56
  # string. A tag also keeps 1 and "1" apart, so a call site that passes an integer
@@ -44,7 +62,7 @@ module EventRail
44
62
  def encode_component(value)
45
63
  tag, bytes = case value
46
64
  when SINGLETON then [ "*", "" ]
47
- when String then [ "s", value.b ]
65
+ when String then [ "s", utf8_bytes(value) ]
48
66
  when true then [ "b", "true" ]
49
67
  when false then [ "b", "false" ]
50
68
  when Integer then [ "i", value.to_s ]
@@ -67,6 +85,18 @@ module EventRail
67
85
  "#{tag}#{bytes.bytesize}:#{bytes}"
68
86
  end
69
87
 
88
+ # A string is hashed as its UTF-8 bytes, with no Unicode normalization: the same
89
+ # text in another normal form is another identity, so a producer must send the same
90
+ # bytes. Bytes that are not valid UTF-8 have no portable spelling at all.
91
+ def utf8_bytes(value)
92
+ utf8 = value.encoding == Encoding::UTF_8 ? value : value.encode(Encoding::UTF_8)
93
+ reject!(value, "valid UTF-8") unless utf8.valid_encoding?
94
+ utf8.b
95
+ rescue EncodingError
96
+ reject!(value, "valid UTF-8")
97
+ end
98
+ private_class_method :utf8_bytes
99
+
70
100
  # An identity component that is a structure cannot be canonicalized without
71
101
  # choosing an ordering and a separator that would then be permanent, and one
72
102
  # that is null cannot identify anything.
@@ -2,7 +2,7 @@ require "active_support/notifications"
2
2
 
3
3
  module EventRail
4
4
  module Internal
5
- # Payloads for the four public notifications.
5
+ # Payloads for the five public notifications.
6
6
  #
7
7
  # Contract, identity, and lineage only. Domain data and extensions are excluded by
8
8
  # construction rather than by filtering, because a notification payload reaches logs
@@ -140,6 +140,7 @@ module EventRail
140
140
  @building = true
141
141
  begin
142
142
  eager_load_conventional_roots
143
+ Stager.validate!
143
144
  prune_stale_declarations
144
145
  @snapshot = build_snapshot
145
146
  ensure
@@ -0,0 +1,99 @@
1
+ module EventRail
2
+ module Internal
3
+ # Finds the application's stager: what `EventRail.stage` hands an event's subscriber
4
+ # jobs to, in place of enqueuing them.
5
+ #
6
+ # Configured by name as a rule. A stager is usually an application model, which cannot be
7
+ # loaded while `config/application.rb` or an initializer runs and is replaced on every
8
+ # reload in development, so a class held by reference would either fail the boot or go
9
+ # stale. The name is resolved again on every staging, the way `belongs_to` resolves
10
+ # `class_name:`. An object is used as given, for a stager that is never reloaded.
11
+ module Stager
12
+ SETTING = "config.event_rail.stager".freeze
13
+ # Ruby's own rule for a constant name, Unicode included: an uppercase letter, then word
14
+ # characters.
15
+ CONSTANT_PATH = /\A(?:::)?[[:upper:]][[:word:]]*(?:::[[:upper:]][[:word:]]*)*\z/
16
+
17
+ module_function
18
+
19
+ # The configured value, or a configuration error when there is no Rails application
20
+ # to read it from: staging has nowhere else to find a stager.
21
+ def configured
22
+ unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
23
+ raise ConfigurationError,
24
+ "EventRail.stage reads #{SETTING} from the Rails application, and there is none"
25
+ end
26
+
27
+ ::Rails.application.config.event_rail&.stager
28
+ end
29
+
30
+ def resolve(configured)
31
+ if configured.nil?
32
+ raise ConfigurationError,
33
+ "EventRail.stage needs a stager; set #{SETTING} to the name of a class that responds to stage(jobs)"
34
+ end
35
+
36
+ stager = configured.is_a?(String) || configured.is_a?(Symbol) ? constantize(configured.to_s) : configured
37
+ return stager if stager.respond_to?(:stage)
38
+
39
+ raise ConfigurationError, "#{SETTING} is #{configured.inspect}, which does not respond to stage(jobs)"
40
+ end
41
+
42
+ # Called from preparation, which runs after the autoloaders are set up and again on
43
+ # every reload, so a misspelled name or a missing method fails the boot that
44
+ # introduced it rather than the first staging. Nothing to check without a Rails
45
+ # application, or with no stager configured: an application that never stages needs
46
+ # none.
47
+ def validate!
48
+ return unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
49
+
50
+ value = configured
51
+ resolve(value) unless value.nil?
52
+ end
53
+
54
+ # Only a constant the name itself denotes being missing is a configuration error: the
55
+ # name, or one of the namespaces it passes through. That is decided by where the lookup
56
+ # failed -- which constant was missing, and in which namespace -- rather than by comparing
57
+ # strings, so a name that passes through an alias of a namespace is judged by the
58
+ # namespace it reaches. Anything else raised while loading the stager is a bug in its
59
+ # file, left alone so its backtrace points there: a NameError naming no constant, one
60
+ # missing from some other namespace, or Zeitwerk's, which means the file exists and
61
+ # defines the wrong thing.
62
+ def constantize(name)
63
+ unless name.match?(CONSTANT_PATH)
64
+ raise ConfigurationError, "#{SETTING} is #{name.inspect}, which is not a constant name"
65
+ end
66
+
67
+ ActiveSupport::Inflector.constantize(name)
68
+ rescue NameError => error
69
+ raise if defined?(::Zeitwerk::NameError) && error.is_a?(::Zeitwerk::NameError)
70
+ raise unless missing_from(name, error)
71
+
72
+ raise ConfigurationError, "#{SETTING} names #{name.inspect}, which is not a constant"
73
+ end
74
+
75
+ def missing_from(name, error)
76
+ return false if error.name.nil?
77
+
78
+ receiver = begin
79
+ error.receiver
80
+ rescue ArgumentError
81
+ return false # raised by hand, with no namespace it was looked up in
82
+ end
83
+
84
+ segments = name.delete_prefix("::").split("::")
85
+ segments.each_index.any? do |index|
86
+ segments[index] == error.name.to_s && namespace(segments.first(index)).equal?(receiver)
87
+ end
88
+ end
89
+
90
+ def namespace(segments)
91
+ return Object if segments.empty?
92
+
93
+ ActiveSupport::Inflector.constantize(segments.join("::"))
94
+ rescue NameError
95
+ nil
96
+ end
97
+ end
98
+ end
99
+ end
@@ -3,28 +3,34 @@ require "securerandom"
3
3
  module EventRail
4
4
  module Internal
5
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
6
+ # derives its ID, fixes occurrence time, and installs lineage. Fanout
7
7
  # is a separate concern layered on top of this.
8
8
  module Stamping
9
9
  module_function
10
10
 
11
- Prepared = Struct.new(:event, :execution, :logical_key, :relayed, keyword_init: true) do
11
+ # `previous` is the record this publication found for its fact, nil for a first attempt, so
12
+ # a caller that fails before delivering anything can put it back (`abandon!`).
13
+ Prepared = Struct.new(:event, :execution, :logical_key, :relayed, :previous, keyword_init: true) do
12
14
  def relayed?
13
15
  relayed
14
16
  end
15
17
  end
16
18
 
17
- def prepare(event, key: nil)
19
+ def prepare(event, identity: nil, source: nil)
18
20
  unless event.is_a?(Event)
19
21
  raise InvalidEvent, "#{event.inspect} is not an EventRail::Event"
20
22
  end
23
+ Metadata.validate_source!(source) unless source.nil?
21
24
 
22
- return relay(event, key: key) if event.stamped?
25
+ return relay(event, identity: identity, source: source) if event.stamped?
26
+
27
+ validate_identity!(event, identity)
28
+ source = (source || event.class.default_source)&.dup&.freeze
29
+ raise InvalidMetadata, "source is required to publish #{event.class}" unless source
23
30
 
24
- validate_key!(key)
25
31
  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
32
+ derived_id = derive_id(event, execution, source, declared_identity(event, identity))
33
+ logical_key = record_key(source, event, derived_id)
28
34
 
29
35
  recorded = execution&.derives_identity? ? execution.record(logical_key) : nil
30
36
  if recorded&.succeeded?
@@ -36,8 +42,7 @@ module EventRail
36
42
  extensions = Extensions.merge!(Current.extensions, event.extensions, error: InvalidEvent)
37
43
  detect_retry_mismatch!(event, recorded, extensions) if recorded
38
44
 
39
- source = event.class.default_source
40
- event_id = recorded&.event&.id || derive_id(event, execution, source, logical_identity)
45
+ event_id = recorded&.event&.id || derived_id
41
46
  occurred_at = event.occurred_at || recorded&.event&.occurred_at || default_occurred_at(execution)
42
47
 
43
48
  stamped = event.send(
@@ -52,7 +57,7 @@ module EventRail
52
57
 
53
58
  execution.record!(logical_key, event: stamped, succeeded: false) if execution&.derives_identity?
54
59
 
55
- Prepared.new(event: stamped, execution: execution, logical_key: logical_key, relayed: false)
60
+ Prepared.new(event: stamped, execution: execution, logical_key: logical_key, relayed: false, previous: recorded)
56
61
  end
57
62
 
58
63
  def succeeded!(prepared)
@@ -62,20 +67,38 @@ module EventRail
62
67
  execution.record!(prepared.logical_key, event: prepared.event, succeeded: true)
63
68
  end
64
69
 
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?
70
+ # Undoes `prepare` for a publication that failed before handing anything over, restoring
71
+ # what the execution recorded before it: nothing for a first attempt, the earlier failed
72
+ # attempt otherwise. Without this a failure that delivered nothing would still turn the
73
+ # next publication of the fact into a retry, bound to a payload nobody received.
74
+ def abandon!(prepared)
75
+ execution = prepared.execution
76
+ return unless execution&.derives_identity?
77
+
78
+ execution.restore!(prepared.logical_key, prepared.previous)
79
+ end
80
+
81
+ # A relayed fact belongs to its origin. Its ID, source, occurrence time, lineage
82
+ # -- an absent causation included -- and extensions are preserved, and the relaying
83
+ # application's own context is deliberately not merged in: local baggage is not
84
+ # part of somebody else's event. Two producers may use the same ID, so the source is
85
+ # part of what makes a relay a repetition, and a relay retried with a different
86
+ # payload is refused exactly as a local publication is.
87
+ def relay(event, identity:, source:)
88
+ unless identity.nil?
72
89
  raise PublicationError,
73
90
  "#{event.event_type.inspect} already carries the event ID #{event.id.inspect}, so a publication " \
74
- "key cannot apply to it"
91
+ "identity cannot apply to it"
92
+ end
93
+ if source && source != event.source
94
+ raise InvalidMetadata,
95
+ "#{event.event_type.inspect} #{event.id.inspect} already comes from #{event.source.inspect}; " \
96
+ "publishing it as #{source.inspect} would re-attribute somebody else's fact"
75
97
  end
76
98
 
77
99
  execution = Execution.current
78
- logical_key = [ event.event_type, event.version, Identity.encode_component(event.id) ].freeze
100
+ logical_key = record_key(event.source, event, event.id)
101
+ recorded = nil
79
102
 
80
103
  if execution&.derives_identity?
81
104
  recorded = execution.record(logical_key)
@@ -84,71 +107,71 @@ module EventRail
84
107
  event_type: event.event_type, version: event.version, event_id: event.id
85
108
  )
86
109
  end
110
+ detect_retry_mismatch!(event, recorded, event.extensions) if recorded
111
+ execution.record!(logical_key, event: event, succeeded: false)
87
112
  end
88
113
 
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)
114
+ Prepared.new(event: event, execution: execution, logical_key: logical_key, relayed: true, previous: recorded)
106
115
  end
107
116
  private_class_method :relay
108
117
 
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
118
+ # One record per published event, whether it was stamped here or arrives stamped:
119
+ # so publishing the stamped result of a failed local publication is a retry of it,
120
+ # and publishing it again after success is a duplicate. Frozen binary copies, so a
121
+ # caller mutating the string it passed cannot move a record, and so a relayed ID is
122
+ # compared as the opaque bytes it is, whatever they hold.
123
+ def record_key(source, event, id)
124
+ [ source.b.freeze, event.event_type, event.version, id.b.freeze ].freeze
125
+ end
126
+ private_class_method :record_key
115
127
 
116
- declared = event.class.identity_by
117
- return Identity::SINGLETON if declared.empty?
128
+ # The fact an event declares itself to be, as a list: an explicit publication
129
+ # identity, or the class's declared identity attributes in declaration order. Nil
130
+ # when it declares none. An explicit identity and a single declared attribute with
131
+ # the same value name the same fact.
132
+ def declared_identity(event, identity)
133
+ return [ identity ] if identity
118
134
 
119
- declared.map { |name| event.public_send(name) }
135
+ declared = event.class.identity_by
136
+ declared.empty? ? nil : declared.map { |name| event.public_send(name) }
120
137
  end
121
- private_class_method :resolve_logical_identity
138
+ private_class_method :declared_identity
122
139
 
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?
140
+ # A publication identity names a fact across every execution, so it is a string --
141
+ # an integer at one call site and its decimal spelling at another would be two
142
+ # facts -- and it cannot contradict an identity the class already declares.
143
+ def validate_identity!(event, identity)
144
+ return if identity.nil?
127
145
 
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}"
146
+ unless event.class.identity_by.empty?
147
+ raise PublicationError,
148
+ "#{event.class} declares #{event.class.identity_by.join(", ")} as its identity, so a publication " \
149
+ "identity cannot apply to it"
150
+ end
151
+ unless identity.is_a?(String) && !identity.empty? && identity.valid_encoding?
152
+ raise PublicationError, "publication identity must be a non-empty string; got #{identity.inspect}"
130
153
  end
131
- if key.bytesize > Limits::MAX_IDENTIFIER_BYTES
132
- raise PublicationError, "publication key exceeds #{Limits::MAX_IDENTIFIER_BYTES} bytes"
154
+ if identity.bytesize > Limits::MAX_IDENTIFIER_BYTES
155
+ raise PublicationError, "publication identity exceeds #{Limits::MAX_IDENTIFIER_BYTES} bytes"
133
156
  end
134
157
  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}"
158
+ private_class_method :validate_identity!
159
+
160
+ # A declared fact's ID depends on nothing but the fact. Without one, an ID derived
161
+ # from the execution is stable across its retries and redeliveries; outside any
162
+ # execution there is nothing stable to derive from, and the ID is random, in time
163
+ # order so a downstream log indexes it well.
164
+ def derive_id(event, execution, source, declared)
165
+ if declared
166
+ Identity.fact(source: source, event_type: event.event_type, identity: declared).freeze
167
+ elsif execution&.derives_identity?
168
+ Identity.execution(
169
+ source: source, job_class: execution.job_class, scope: execution.scope,
170
+ event_type: event.event_type, version: event.version
171
+ ).freeze
172
+ else
173
+ SecureRandom.uuid_v7.freeze
142
174
  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
175
  end
153
176
  private_class_method :derive_id
154
177
 
@@ -106,8 +106,11 @@ module EventRail
106
106
  entry["extensions"], error: InvalidContext
107
107
  )
108
108
 
109
+ # The scope an undeclared follow-up's ID derives from. A subscriber's is the event
110
+ # it handles, by source as well as ID, since two producers may use one ID; its
111
+ # logical message and causation stay the event's ID.
109
112
  execution = Internal::Execution.new(
110
- job_class: self.class.name, scope: message_id, started_at: started_at
113
+ job_class: self.class.name, scope: event ? [ event.source, event.id ] : message_id, started_at: started_at
111
114
  )
112
115
 
113
116
  Internal::Context.establish(
@@ -7,6 +7,9 @@ module EventRail
7
7
  #
8
8
  # publication = EventRail.publish(Orders::OrderPlaced.new(order_id: order.id))
9
9
  #
10
+ # `identity:` names the fact for an event whose class declares no `identity_by`;
11
+ # `source:` publishes a locally built event on behalf of another producer.
12
+ #
10
13
  # Individual enqueue calls, not `perform_all_later`. Bulk enqueue skips each job's
11
14
  # own `enqueue` callbacks on several adapters, and those callbacks are exactly where
12
15
  # an application puts uniqueness, concurrency, and feature-flag decisions that this
@@ -15,10 +18,12 @@ module EventRail
15
18
  # Delivery is at-least-once. Jobs already accepted are not rolled back when a later
16
19
  # subscriber's enqueue fails, and the retry repeats complete fanout under the same
17
20
  # event ID, so a subscriber may see the same event more than once.
18
- def publish(event, key: nil)
21
+ def publish(event, identity: nil, source: nil, **unknown)
22
+ reject_unknown_keywords!(:publish, unknown)
23
+
19
24
  Internal::Transaction.check!
20
25
 
21
- prepared = Internal::Stamping.prepare(event, key: key)
26
+ prepared = Internal::Stamping.prepare(event, identity: identity, source: source)
22
27
  stamped = prepared.event
23
28
  subscribers = Internal::Registry.subscribers_for(stamped.class)
24
29
 
@@ -45,6 +50,15 @@ module EventRail
45
50
  end
46
51
 
47
52
  private
53
+ def reject_unknown_keywords!(verb, unknown)
54
+ if unknown.key?(:key)
55
+ raise ArgumentError,
56
+ "#{verb}'s key: is now identity:, and it names a fact across every job rather than within one; " \
57
+ "check the value names one occurrence per source and event type before renaming it"
58
+ end
59
+ raise ArgumentError, "unknown keyword#{"s" if unknown.size > 1}: #{unknown.keys.map(&:inspect).join(", ")}" if unknown.any?
60
+ end
61
+
48
62
  # Three outcomes, not two. Active Job returns false both when an adapter reports
49
63
  # failure and when an enqueue callback aborts, so the return value alone cannot
50
64
  # tell a fault from a decision. The job instance can, through `enqueue_error`, and
@@ -12,6 +12,12 @@ module EventRail
12
12
  config.event_rail = ActiveSupport::OrderedOptions.new
13
13
  config.event_rail.roots = Internal::Registry::DEFAULT_ROOTS.dup
14
14
 
15
+ # What `EventRail.stage` hands an event's subscriber jobs to: the name of a constant,
16
+ # resolved on every staging so a reloaded class is always the current one, or an object
17
+ # for a stager that is never reloaded. Either responds to `stage(jobs)`. Assigned
18
+ # explicitly, so the option exists before an application sets it.
19
+ config.event_rail.stager = nil
20
+
15
21
  config.to_prepare do
16
22
  # Unqualified, so lexical lookup reaches the private Internal namespace that a
17
23
  # qualified EventRail::Internal reference would be refused.
@@ -0,0 +1,79 @@
1
+ require "active_support/notifications"
2
+
3
+ module EventRail
4
+ class << self
5
+ # Stages an event: stamps it exactly as `publish` does, builds one job per declared
6
+ # subscriber without enqueuing any, and hands them all to the application's stager in one
7
+ # call, inside the caller's database transaction.
8
+ #
9
+ # Order.transaction do
10
+ # order.update!(state: "shipped")
11
+ # EventRail.stage(Orders::OrderShipped.new(order_id: order.id))
12
+ # end
13
+ #
14
+ # The stager (`config.event_rail.stager`) persists the jobs with that transaction, all or
15
+ # nothing, or raises, and hands them to the queue once it commits. So the intent to
16
+ # deliver commits or rolls back with the state change it reports. Delivery itself is the
17
+ # stager's: its hand-over, its relay, the queue, and each subscriber's own enqueue
18
+ # callbacks, which run when a job is enqueued rather than here.
19
+ #
20
+ # Unlike `publish`, staging inside an open transaction is the point, so there is no
21
+ # transaction check. Whether the right transaction is open is the stager's to judge: only
22
+ # it knows which database it writes to.
23
+ def stage(event, identity: nil, source: nil, **unknown)
24
+ reject_unknown_keywords!(:stage, unknown)
25
+
26
+ # Every check that can fail on configuration or readiness runs before stamping, which
27
+ # records the fact in the current execution: a failure here must not leave a record that
28
+ # makes the next staging in the same attempt look like a retry. Readiness before the
29
+ # stager, so staging from an initializer says "not ready" rather than failing to find a
30
+ # class the autoloader cannot load yet.
31
+ configured = Internal::Stager.configured
32
+ snapshot = Internal::Registry.snapshot
33
+ stager = Internal::Stager.resolve(configured)
34
+
35
+ prepared = Internal::Stamping.prepare(event, identity: identity, source: source)
36
+ stamped = prepared.event
37
+ handed_over = false
38
+
39
+ begin
40
+ jobs = snapshot.subscribers_for(stamped.class).map { |job_class| staged_job(job_class, stamped) }.freeze
41
+
42
+ ActiveSupport::Notifications.instrument("stage.event_rail", Internal::Notifications.payload_for(stamped)) do |payload|
43
+ payload[:subscriber_count] = jobs.length
44
+ handed_over = true
45
+
46
+ # Called even with no jobs, so the stager's own transaction check applies whether or
47
+ # not the event has subscribers yet: otherwise adding the first one would make a
48
+ # previously silent call site start raising. A copy, so the stager may sort or trim
49
+ # its argument without touching what the result reports.
50
+ stager.stage(jobs.dup)
51
+ end
52
+ ensure
53
+ # A failure before the stager was called -- a subscriber that cannot be built, a
54
+ # notification handler failing as the block starts -- handed nothing over, so it must
55
+ # not leave the fact recorded: the next staging in this attempt is a first staging, not
56
+ # a retry bound to a payload no stager saw. From the stager on, a failure leaves the
57
+ # fact unfinished, and staging it again is a retry.
58
+ Internal::Stamping.abandon!(prepared) unless handed_over
59
+ end
60
+
61
+ # After the notification block, as for `publish`.
62
+ Internal::Stamping.succeeded!(prepared)
63
+
64
+ StagedPublication.new(event: stamped, staged_jobs: jobs)
65
+ end
66
+
67
+ private
68
+ # The job `perform_later` would have built, without the enqueue. Its context entry is
69
+ # built now, while the staging context is current, so the stager may serialize the job
70
+ # whenever and wherever it likes and still write what enqueuing it here would have
71
+ # written. Through the entry alone, not `serialize`: serializing would resolve and
72
+ # memoize the job's queue and priority blocks inside the caller's context and run its
73
+ # argument serializers for nothing. Every subscriber includes JobContext; preparation
74
+ # refuses one that does not.
75
+ def staged_job(job_class, event)
76
+ job_class.new(event).tap { |job| job.send(:__event_rail_entry__) }
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,34 @@
1
+ module EventRail
2
+ # What `EventRail.stage` returns: the stamped fact, and the subscriber jobs handed to the
3
+ # stager in place of being enqueued.
4
+ #
5
+ # Not a `Publication`. Accepted and skipped are outcomes of an enqueue, and staging performs
6
+ # none: whether each job reaches the queue, and whether a subscriber's own enqueue callback
7
+ # declines it, is decided later, when the stager hands it over. The jobs are here so a
8
+ # caller can match their IDs with what its stager wrote.
9
+ class StagedPublication
10
+ attr_reader :event, :staged_jobs
11
+
12
+ def initialize(event:, staged_jobs:)
13
+ @event = event
14
+ @staged_jobs = staged_jobs.dup.freeze
15
+ freeze
16
+ end
17
+
18
+ def staged_subscribers
19
+ staged_jobs.map(&:class)
20
+ end
21
+
22
+ def subscriber_count
23
+ staged_jobs.length
24
+ end
25
+
26
+ def id
27
+ event.id
28
+ end
29
+
30
+ def inspect
31
+ "#<EventRail::StagedPublication event=#{event.inspect} staged=#{subscriber_count}>"
32
+ end
33
+ end
34
+ end
@@ -1,3 +1,3 @@
1
1
  module EventRail
2
- VERSION = "0.3.0"
2
+ VERSION = "0.5.0"
3
3
  end