event_rail 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +47 -0
- data/CONTRIBUTING.md +14 -0
- data/MIT-LICENSE +20 -0
- data/README.md +364 -0
- data/SECURITY.md +3 -0
- data/lib/event_rail/contract.rb +44 -0
- data/lib/event_rail/current.rb +95 -0
- data/lib/event_rail/data.rb +4 -0
- data/lib/event_rail/envelope.rb +123 -0
- data/lib/event_rail/errors.rb +196 -0
- data/lib/event_rail/event.rb +258 -0
- data/lib/event_rail/internal/attribute_record.rb +272 -0
- data/lib/event_rail/internal/context.rb +32 -0
- data/lib/event_rail/internal/contract_index.rb +28 -0
- data/lib/event_rail/internal/event_serializer.rb +148 -0
- data/lib/event_rail/internal/execution.rb +86 -0
- data/lib/event_rail/internal/extensions.rb +69 -0
- data/lib/event_rail/internal/identity.rb +90 -0
- data/lib/event_rail/internal/notifications.rb +42 -0
- data/lib/event_rail/internal/portable_value.rb +108 -0
- data/lib/event_rail/internal/registry.rb +224 -0
- data/lib/event_rail/internal/stamping.rb +185 -0
- data/lib/event_rail/internal/subscriber_execution.rb +61 -0
- data/lib/event_rail/internal/timestamp.rb +85 -0
- data/lib/event_rail/internal/transaction.rb +33 -0
- data/lib/event_rail/internal/types.rb +441 -0
- data/lib/event_rail/job_context.rb +124 -0
- data/lib/event_rail/limits.rb +31 -0
- data/lib/event_rail/metadata.rb +94 -0
- data/lib/event_rail/portable_type.rb +35 -0
- data/lib/event_rail/publication.rb +33 -0
- data/lib/event_rail/publish.rb +88 -0
- data/lib/event_rail/railtie.rb +15 -0
- data/lib/event_rail/subscriptions.rb +59 -0
- data/lib/event_rail/version.rb +3 -0
- data/lib/event_rail.rb +52 -0
- metadata +184 -0
|
@@ -0,0 +1,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
|