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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +91 -1
- data/README.md +283 -19
- data/lib/event_rail/current.rb +9 -1
- data/lib/event_rail/errors.rb +7 -4
- data/lib/event_rail/event.rb +6 -4
- data/lib/event_rail/internal/execution.rb +9 -0
- data/lib/event_rail/internal/identity.rb +51 -21
- data/lib/event_rail/internal/notifications.rb +1 -1
- data/lib/event_rail/internal/registry.rb +1 -0
- data/lib/event_rail/internal/stager.rb +99 -0
- data/lib/event_rail/internal/stamping.rb +93 -70
- data/lib/event_rail/job_context.rb +4 -1
- data/lib/event_rail/publish.rb +16 -2
- data/lib/event_rail/railtie.rb +6 -0
- data/lib/event_rail/stage.rb +79 -0
- data/lib/event_rail/staged_publication.rb +34 -0
- data/lib/event_rail/version.rb +1 -1
- data/lib/event_rail.rb +3 -0
- metadata +7 -4
|
@@ -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
|
-
#
|
|
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
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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
|
-
#
|
|
19
|
-
#
|
|
20
|
-
|
|
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
|
-
#
|
|
26
|
-
#
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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
|
|
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
|
|
@@ -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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
27
|
-
logical_key =
|
|
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
|
-
|
|
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
|
-
#
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
unless
|
|
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
|
-
"
|
|
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 =
|
|
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
|
-
|
|
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
|
-
#
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
|
135
|
+
declared = event.class.identity_by
|
|
136
|
+
declared.empty? ? nil : declared.map { |name| event.public_send(name) }
|
|
120
137
|
end
|
|
121
|
-
private_class_method :
|
|
138
|
+
private_class_method :declared_identity
|
|
122
139
|
|
|
123
|
-
# A
|
|
124
|
-
#
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
129
|
-
raise PublicationError,
|
|
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
|
|
132
|
-
raise PublicationError, "publication
|
|
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 :
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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(
|
data/lib/event_rail/publish.rb
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
data/lib/event_rail/railtie.rb
CHANGED
|
@@ -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
|
data/lib/event_rail/version.rb
CHANGED