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,124 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
|
|
4
|
+
module EventRail
|
|
5
|
+
# Opt a regular Active Job base class into logical context propagation:
|
|
6
|
+
#
|
|
7
|
+
# class ApplicationJob < ActiveJob::Base
|
|
8
|
+
# include EventRail::JobContext
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# EventRail ships no initializer, install generator, or global prepend. Prepending
|
|
12
|
+
# every `ActiveJob::Base` would change serialization for jobs whose owners never
|
|
13
|
+
# asked, and editing `ApplicationJob` from a generator assumes there is exactly one
|
|
14
|
+
# and that it is unmodified. The explicit inclusion keeps ownership visible, and
|
|
15
|
+
# application preparation fails with a precise message when a declared subscriber
|
|
16
|
+
# is missing it.
|
|
17
|
+
module JobContext
|
|
18
|
+
extend ActiveSupport::Concern
|
|
19
|
+
|
|
20
|
+
# One reserved key in the job's serialized data, carrying only JSON primitives.
|
|
21
|
+
ENTRY_KEY = "event_rail_context".freeze
|
|
22
|
+
ENTRY_VERSION = 1
|
|
23
|
+
SUPPORTED_ENTRY_VERSIONS = [ ENTRY_VERSION ].freeze
|
|
24
|
+
|
|
25
|
+
included do
|
|
26
|
+
around_perform do |job, block|
|
|
27
|
+
job.send(:__event_rail_around_perform__, &block)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The entry is established exactly once per job instance and then re-emitted
|
|
32
|
+
# unchanged. Reading ambient context here instead would lose lineage on the path
|
|
33
|
+
# this design depends on most: when `retry_on` retries, Active Job serializes
|
|
34
|
+
# *after* the surrounding perform callbacks have already restored the previous
|
|
35
|
+
# context, so an ambient read would generate a fresh root on every retry. The
|
|
36
|
+
# instance survives, because `retry_job` re-enqueues the same object.
|
|
37
|
+
def serialize
|
|
38
|
+
super.merge(ENTRY_KEY => __event_rail_entry__)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def deserialize(job_data)
|
|
42
|
+
super
|
|
43
|
+
@__event_rail_entry__ = __event_rail_read_entry__(job_data[ENTRY_KEY])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
# A subscriber's logical message is the event it is handling rather than the job
|
|
48
|
+
# that delivers it, so the subscriber integration overrides this. A regular job
|
|
49
|
+
# has no delivered event and speaks for itself.
|
|
50
|
+
def __event_rail_delivered_event__
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def __event_rail_entry__
|
|
55
|
+
@__event_rail_entry__ ||= __event_rail_build_entry__
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# A job queued before context integration was deployed has no entry, and must
|
|
59
|
+
# still run: it becomes the root of its own causal chain rather than failing
|
|
60
|
+
# deserialization.
|
|
61
|
+
def __event_rail_build_entry__
|
|
62
|
+
message_id = job_id || SecureRandom.uuid
|
|
63
|
+
originated_at = Current.originated_at || Time.now.utc
|
|
64
|
+
|
|
65
|
+
{
|
|
66
|
+
"v" => ENTRY_VERSION,
|
|
67
|
+
"message_id" => message_id,
|
|
68
|
+
"correlation_id" => Current.correlation_id || message_id,
|
|
69
|
+
"causation_id" => Current.message_id,
|
|
70
|
+
"originated_at" => Internal::Timestamp.written(originated_at),
|
|
71
|
+
"extensions" => Current.extensions
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def __event_rail_read_entry__(raw)
|
|
76
|
+
return nil unless raw.is_a?(Hash)
|
|
77
|
+
|
|
78
|
+
version = raw["v"]
|
|
79
|
+
unless SUPPORTED_ENTRY_VERSIONS.include?(version)
|
|
80
|
+
raise UnsupportedFormatError.new(
|
|
81
|
+
format_version: version, supported_format_versions: SUPPORTED_ENTRY_VERSIONS
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The adapter owns the hash it handed us, and the entry is mutated during
|
|
86
|
+
# execution to record when the attempt started.
|
|
87
|
+
raw.dup
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def __event_rail_around_perform__(&block)
|
|
91
|
+
entry = __event_rail_entry__
|
|
92
|
+
event = __event_rail_delivered_event__
|
|
93
|
+
|
|
94
|
+
# Recorded on the entry at the first attempt and re-emitted by every later
|
|
95
|
+
# serialization, which is what makes a default occurrence time stable across
|
|
96
|
+
# this job's Active Job retries rather than tracking the retry's own start.
|
|
97
|
+
entry["execution_started_at"] ||= Internal::Timestamp.written(Time.now.utc)
|
|
98
|
+
started_at = Internal::Timestamp.normalize(
|
|
99
|
+
entry["execution_started_at"], field: "execution_started_at"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
message_id = event ? event.id : entry["message_id"]
|
|
103
|
+
correlation_id = event ? event.correlation_id : entry["correlation_id"]
|
|
104
|
+
causation_id = event ? event.causation_id : entry["causation_id"]
|
|
105
|
+
extensions = event ? event.extensions : Internal::Extensions.validate!(
|
|
106
|
+
entry["extensions"], error: InvalidContext
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
execution = Internal::Execution.new(
|
|
110
|
+
job_class: self.class.name, scope: message_id, started_at: started_at
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
Internal::Context.establish(
|
|
114
|
+
message_id: message_id,
|
|
115
|
+
correlation_id: correlation_id,
|
|
116
|
+
causation_id: causation_id,
|
|
117
|
+
originated_at: Internal::Timestamp.normalize(entry["originated_at"], field: "originated_at"),
|
|
118
|
+
extensions: extensions
|
|
119
|
+
) do
|
|
120
|
+
Internal::Execution.wrap(execution) { block.call }
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module EventRail
|
|
2
|
+
module Limits
|
|
3
|
+
MAX_IDENTIFIER_BYTES = 512
|
|
4
|
+
MAX_SOURCE_BYTES = 255
|
|
5
|
+
MAX_EVENT_TYPE_BYTES = 255
|
|
6
|
+
MAX_EXTENSION_ENTRIES = 32
|
|
7
|
+
MAX_EXTENSION_KEY_BYTES = 64
|
|
8
|
+
MAX_EXTENSION_VALUE_BYTES = 1_024
|
|
9
|
+
MAX_EXTENSIONS_BYTES = 8_192
|
|
10
|
+
|
|
11
|
+
# Raw portable structures are application-shaped, so they need a bound that
|
|
12
|
+
# fails during construction rather than as a stack overflow inside a recursive
|
|
13
|
+
# wire reconstruction on a worker.
|
|
14
|
+
MAX_RAW_DEPTH = 32
|
|
15
|
+
|
|
16
|
+
# Active Job's argument encoding claims this prefix for its own hash keys, so a
|
|
17
|
+
# payload key using it would either collide with an encoding key or survive as
|
|
18
|
+
# an unintended instruction to the decoder.
|
|
19
|
+
ACTIVE_JOB_RESERVED_KEY_PREFIX = "_aj_".freeze
|
|
20
|
+
|
|
21
|
+
RESERVED_EXTENSION_KEYS = %w[
|
|
22
|
+
id
|
|
23
|
+
source
|
|
24
|
+
occurred_at
|
|
25
|
+
correlation_id
|
|
26
|
+
causation_id
|
|
27
|
+
traceparent
|
|
28
|
+
tracestate
|
|
29
|
+
].freeze
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
module EventRail
|
|
2
|
+
class Metadata
|
|
3
|
+
attr_reader :id, :source, :occurred_at, :correlation_id, :causation_id, :extensions
|
|
4
|
+
|
|
5
|
+
def self.proposed(occurred_at: nil, extensions: {})
|
|
6
|
+
new(occurred_at: occurred_at, extensions: extensions, complete: false)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def self.complete(id:, source:, occurred_at:, correlation_id:, causation_id: nil, extensions: {})
|
|
10
|
+
new(
|
|
11
|
+
id: id,
|
|
12
|
+
source: source,
|
|
13
|
+
occurred_at: occurred_at,
|
|
14
|
+
correlation_id: correlation_id,
|
|
15
|
+
causation_id: causation_id,
|
|
16
|
+
extensions: extensions,
|
|
17
|
+
complete: true
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.validate_source!(value)
|
|
22
|
+
validate_string!(value, field: "source", maximum: Limits::MAX_SOURCE_BYTES)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.validate_identifier!(value, field:, optional: false)
|
|
26
|
+
return if optional && value.nil?
|
|
27
|
+
|
|
28
|
+
validate_string!(value, field: field, maximum: Limits::MAX_IDENTIFIER_BYTES)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.validate_string!(value, field:, maximum:)
|
|
32
|
+
unless value.is_a?(String) && !value.empty? && value.valid_encoding?
|
|
33
|
+
raise InvalidMetadata, "#{field} must be a non-empty valid string"
|
|
34
|
+
end
|
|
35
|
+
if value.bytesize > maximum
|
|
36
|
+
raise InvalidMetadata, "#{field} exceeds #{maximum} bytes"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
value
|
|
40
|
+
end
|
|
41
|
+
private_class_method :validate_string!
|
|
42
|
+
|
|
43
|
+
def initialize(id: nil, source: nil, occurred_at: nil, correlation_id: nil, causation_id: nil, extensions: {}, complete:)
|
|
44
|
+
if complete
|
|
45
|
+
self.class.validate_identifier!(id, field: "id")
|
|
46
|
+
self.class.validate_source!(source)
|
|
47
|
+
self.class.validate_identifier!(correlation_id, field: "correlation_id")
|
|
48
|
+
self.class.validate_identifier!(causation_id, field: "causation_id", optional: true)
|
|
49
|
+
elsif [ id, source, correlation_id, causation_id ].any?
|
|
50
|
+
raise InvalidMetadata, "local event metadata cannot contain identity, source, correlation, or causation"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
@id = duplicate_and_freeze(id)
|
|
54
|
+
@source = duplicate_and_freeze(source)
|
|
55
|
+
@occurred_at = Internal::Timestamp.cast(occurred_at, field: "occurred_at")
|
|
56
|
+
if complete && @occurred_at.nil?
|
|
57
|
+
raise InvalidMetadata, "occurred_at is required for complete metadata"
|
|
58
|
+
end
|
|
59
|
+
@correlation_id = duplicate_and_freeze(correlation_id)
|
|
60
|
+
@causation_id = duplicate_and_freeze(causation_id)
|
|
61
|
+
@extensions = validate_extensions(extensions)
|
|
62
|
+
@complete = complete
|
|
63
|
+
freeze
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def complete?
|
|
67
|
+
@complete
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Value equality, so two events carrying the same fact and the same lineage
|
|
71
|
+
# compare equal across a serialization boundary.
|
|
72
|
+
COMPARED_FIELDS = [ :id, :source, :occurred_at, :correlation_id, :causation_id, :extensions ].freeze
|
|
73
|
+
|
|
74
|
+
def ==(other)
|
|
75
|
+
other.instance_of?(self.class) &&
|
|
76
|
+
other.complete? == complete? &&
|
|
77
|
+
COMPARED_FIELDS.all? { |field| other.public_send(field) == public_send(field) }
|
|
78
|
+
end
|
|
79
|
+
alias_method :eql?, :==
|
|
80
|
+
|
|
81
|
+
def hash
|
|
82
|
+
([ self.class, @complete ] + COMPARED_FIELDS.map { |field| public_send(field) }).hash
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
def duplicate_and_freeze(value)
|
|
87
|
+
value&.dup&.freeze
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def validate_extensions(value)
|
|
91
|
+
Internal::Extensions.validate!(value, error: InvalidMetadata)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module EventRail
|
|
2
|
+
# Opt-in contract for a custom Active Model type used as an EventRail attribute
|
|
3
|
+
# type.
|
|
4
|
+
#
|
|
5
|
+
# Rails already defines `serialize` and `deserialize`, so this adds no vocabulary.
|
|
6
|
+
# What it adds is a stricter target: `ActiveModel::Type::Value#serialize` is
|
|
7
|
+
# documented as producing a value "usable by the database", and database drivers
|
|
8
|
+
# accept Date, Time, and BigDecimal objects. A queue is not a database. Active Job
|
|
9
|
+
# does not recurse into a custom serializer's output, so a Ruby object left there
|
|
10
|
+
# reaches the adapter raw, where a JSON-native adapter rejects the job and a JSON
|
|
11
|
+
# column stringifies it and loses precision.
|
|
12
|
+
#
|
|
13
|
+
# A type therefore promises that `serialize` returns a JSON primitive, array, or
|
|
14
|
+
# string-keyed hash, and that `deserialize` reconstructs the identical cast value
|
|
15
|
+
# from it. `portable_examples` is what makes the promise checkable: EventRail
|
|
16
|
+
# round-trips every example through JSON when the attribute is declared, so a type
|
|
17
|
+
# that cannot hold up fails at class definition instead of at the first enqueue.
|
|
18
|
+
#
|
|
19
|
+
# class MoneyType < ActiveModel::Type::Value
|
|
20
|
+
# include EventRail::PortableType
|
|
21
|
+
#
|
|
22
|
+
# def cast(value) = value.is_a?(Money) ? value : Money.parse(value)
|
|
23
|
+
# def serialize(value) = value&.to_s
|
|
24
|
+
# def deserialize(value) = value && Money.parse(value)
|
|
25
|
+
# def portable_examples = [ Money.new(0, "USD"), Money.new(1250, "EUR") ]
|
|
26
|
+
# end
|
|
27
|
+
module PortableType
|
|
28
|
+
# Cast values covering every written shape the type can produce. At least one
|
|
29
|
+
# is required, and each must survive serialize, a JSON encoding cycle, and
|
|
30
|
+
# deserialize while comparing equal to the value it started as.
|
|
31
|
+
def portable_examples
|
|
32
|
+
raise NotImplementedError, "#{self.class} must define portable_examples"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module EventRail
|
|
2
|
+
# What `EventRail.publish` returns: the stamped fact, plus which subscribers took it
|
|
3
|
+
# and which deliberately declined it.
|
|
4
|
+
#
|
|
5
|
+
# Delivery outcome is not event metadata -- an event is the same fact regardless of
|
|
6
|
+
# who received it -- so it lives here instead. The skipped list is the part that
|
|
7
|
+
# matters operationally: a subscriber's own uniqueness, concurrency, or feature-flag
|
|
8
|
+
# callback aborting its enqueue is a decision, not a fault, and this is where an
|
|
9
|
+
# application can see it happened.
|
|
10
|
+
class Publication
|
|
11
|
+
attr_reader :event, :accepted_subscribers, :skipped_subscribers
|
|
12
|
+
|
|
13
|
+
def initialize(event:, accepted_subscribers:, skipped_subscribers:)
|
|
14
|
+
@event = event
|
|
15
|
+
@accepted_subscribers = accepted_subscribers.freeze
|
|
16
|
+
@skipped_subscribers = skipped_subscribers.freeze
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def subscriber_count
|
|
21
|
+
accepted_subscribers.length + skipped_subscribers.length
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def id
|
|
25
|
+
event.id
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def inspect
|
|
29
|
+
"#<EventRail::Publication event=#{event.inspect} accepted=#{accepted_subscribers.length} " \
|
|
30
|
+
"skipped=#{skipped_subscribers.length}>"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
require "active_support/notifications"
|
|
2
|
+
|
|
3
|
+
module EventRail
|
|
4
|
+
class << self
|
|
5
|
+
# Publishes an event: stamps it with identity, source, occurrence time, and
|
|
6
|
+
# lineage, then calls ordinary `perform_later` on each declared subscriber.
|
|
7
|
+
#
|
|
8
|
+
# publication = EventRail.publish(Orders::OrderPlaced.new(order_id: order.id))
|
|
9
|
+
#
|
|
10
|
+
# Individual enqueue calls, not `perform_all_later`. Bulk enqueue skips each job's
|
|
11
|
+
# own `enqueue` callbacks on several adapters, and those callbacks are exactly where
|
|
12
|
+
# an application puts uniqueness, concurrency, and feature-flag decisions that this
|
|
13
|
+
# design promises stay authoritative.
|
|
14
|
+
#
|
|
15
|
+
# Delivery is at-least-once. Jobs already accepted are not rolled back when a later
|
|
16
|
+
# subscriber's enqueue fails, and the retry repeats complete fanout under the same
|
|
17
|
+
# event ID, so a subscriber may see the same event more than once.
|
|
18
|
+
def publish(event, key: nil)
|
|
19
|
+
Internal::Transaction.check!
|
|
20
|
+
|
|
21
|
+
prepared = Internal::Stamping.prepare(event, key: key)
|
|
22
|
+
stamped = prepared.event
|
|
23
|
+
subscribers = Internal::Registry.subscribers_for(stamped.class)
|
|
24
|
+
|
|
25
|
+
accepted = []
|
|
26
|
+
skipped = []
|
|
27
|
+
|
|
28
|
+
ActiveSupport::Notifications.instrument("publish.event_rail", Internal::Notifications.payload_for(stamped)) do |payload|
|
|
29
|
+
payload[:subscriber_count] = subscribers.length
|
|
30
|
+
|
|
31
|
+
subscribers.each do |job_class|
|
|
32
|
+
enqueue_subscriber(job_class, stamped, accepted, skipped)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
payload[:accepted] = accepted.length
|
|
36
|
+
payload[:skipped] = skipped.length
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Successful once every subscriber was either accepted or deliberately skipped. A
|
|
40
|
+
# skipped delivery is a decision, so treating it as a failure would make one
|
|
41
|
+
# subscriber's guard retry the publisher forever.
|
|
42
|
+
Internal::Stamping.succeeded!(prepared)
|
|
43
|
+
|
|
44
|
+
Publication.new(event: stamped, accepted_subscribers: accepted, skipped_subscribers: skipped)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
# Three outcomes, not two. Active Job returns false both when an adapter reports
|
|
49
|
+
# failure and when an enqueue callback aborts, so the return value alone cannot
|
|
50
|
+
# tell a fault from a decision. The job instance can, through `enqueue_error`, and
|
|
51
|
+
# the block form of `perform_later` yields the job even when the call returns
|
|
52
|
+
# false.
|
|
53
|
+
def enqueue_subscriber(job_class, event, accepted, skipped)
|
|
54
|
+
payload = Internal::Notifications.payload_for(event).merge(job_class: job_class.name)
|
|
55
|
+
|
|
56
|
+
ActiveSupport::Notifications.instrument("enqueue_subscriber.event_rail", payload) do
|
|
57
|
+
job = nil
|
|
58
|
+
|
|
59
|
+
begin
|
|
60
|
+
result = job_class.perform_later(event) { |enqueued| job = enqueued }
|
|
61
|
+
rescue StandardError => cause
|
|
62
|
+
payload[:outcome] = "failed"
|
|
63
|
+
raise enqueue_error(event, job_class, accepted, skipped), cause: cause
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
if result == false && job&.enqueue_error
|
|
67
|
+
payload[:outcome] = "failed"
|
|
68
|
+
raise enqueue_error(event, job_class, accepted, skipped), cause: job.enqueue_error
|
|
69
|
+
elsif result == false
|
|
70
|
+
payload[:outcome] = "skipped"
|
|
71
|
+
skipped << job_class
|
|
72
|
+
else
|
|
73
|
+
payload[:outcome] = "accepted"
|
|
74
|
+
accepted << job_class
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def enqueue_error(event, job_class, accepted, skipped)
|
|
80
|
+
EnqueueError.new(
|
|
81
|
+
event: event,
|
|
82
|
+
accepted_subscribers: accepted.dup,
|
|
83
|
+
skipped_subscribers: skipped.dup,
|
|
84
|
+
failed_subscriber: job_class
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module EventRail
|
|
2
|
+
class Railtie < ::Rails::Railtie
|
|
3
|
+
# Preparation, not an initializer: it has to run again on every reload, and it has
|
|
4
|
+
# to run after the main autoloader exists. Rails sets that loader up in a finisher
|
|
5
|
+
# that runs after config/initializers and runs prepare callbacks before
|
|
6
|
+
# eager_load!, so no reloadable constant outside a conventional root can already be
|
|
7
|
+
# loaded here -- which is exactly why one declared there raises later instead of
|
|
8
|
+
# silently receiving nothing.
|
|
9
|
+
config.to_prepare do
|
|
10
|
+
# Unqualified, so lexical lookup reaches the private Internal namespace that a
|
|
11
|
+
# qualified EventRail::Internal reference would be refused.
|
|
12
|
+
Internal::Registry.prepare
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module EventRail
|
|
2
|
+
# The class macro that declares a subscription. Extended onto `ActiveJob::Base`, so
|
|
3
|
+
# it is available on any ordinary job:
|
|
4
|
+
#
|
|
5
|
+
# class Billing::OnOrderPlacedJob < ApplicationJob
|
|
6
|
+
# subscribes_to Orders::OrderPlaced
|
|
7
|
+
#
|
|
8
|
+
# def perform(event)
|
|
9
|
+
# Billing.charge(order_id: event.order_id, idempotency_key: event.id)
|
|
10
|
+
# end
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# Only class methods are added, and nothing about serialization or execution
|
|
14
|
+
# changes for a job that never calls the macro. It is available on every job rather
|
|
15
|
+
# than only on jobs that include `EventRail::JobContext` so that a subscriber
|
|
16
|
+
# missing that inclusion fails application preparation with a precise message
|
|
17
|
+
# instead of a bare NoMethodError on the macro.
|
|
18
|
+
module Subscriptions
|
|
19
|
+
# Declares the exact event classes this job handles. The relationship is recorded
|
|
20
|
+
# for preparation to validate: `perform` is usually defined after this line, and
|
|
21
|
+
# whether this class has subclasses is not knowable until everything is loaded, so
|
|
22
|
+
# only what can be judged immediately is judged here.
|
|
23
|
+
def subscribes_to(*event_classes)
|
|
24
|
+
if event_classes.empty?
|
|
25
|
+
raise DeclarationError, "#{self} must name at least one event class to subscribe to"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
own = (@event_rail_subscriptions ||= [])
|
|
29
|
+
|
|
30
|
+
event_classes.each do |event_class|
|
|
31
|
+
unless event_class.is_a?(Class) && event_class < EventRail::Event
|
|
32
|
+
raise DeclarationError,
|
|
33
|
+
"#{self} cannot subscribe to #{event_class.inspect}, which is not an EventRail::Event class"
|
|
34
|
+
end
|
|
35
|
+
if own.include?(event_class)
|
|
36
|
+
raise DeclarationError, "#{self} already declares a subscription to #{event_class}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
own << event_class
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
Internal::Registry.declare(self)
|
|
43
|
+
include Internal::SubscriberExecution unless include?(Internal::SubscriberExecution)
|
|
44
|
+
|
|
45
|
+
event_rail_subscriptions
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# This class's own declarations. Deliberately not inherited: a subclass of a
|
|
49
|
+
# subscriber is a different job, and silently registering it as a second
|
|
50
|
+
# subscriber would double every delivery.
|
|
51
|
+
def event_rail_subscriptions
|
|
52
|
+
(@event_rail_subscriptions || []).dup.freeze
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def event_rail_subscriber?
|
|
56
|
+
!(@event_rail_subscriptions || []).empty?
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
data/lib/event_rail.rb
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require "active_job"
|
|
2
|
+
require "active_model"
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "rails/railtie"
|
|
5
|
+
|
|
6
|
+
require "event_rail/version"
|
|
7
|
+
require "event_rail/errors"
|
|
8
|
+
require "event_rail/limits"
|
|
9
|
+
require "event_rail/portable_type"
|
|
10
|
+
require "event_rail/internal/portable_value"
|
|
11
|
+
require "event_rail/internal/extensions"
|
|
12
|
+
require "event_rail/internal/types"
|
|
13
|
+
require "event_rail/internal/attribute_record"
|
|
14
|
+
require "event_rail/data"
|
|
15
|
+
require "event_rail/internal/timestamp"
|
|
16
|
+
require "event_rail/metadata"
|
|
17
|
+
require "event_rail/event"
|
|
18
|
+
require "event_rail/internal/contract_index"
|
|
19
|
+
require "event_rail/internal/identity"
|
|
20
|
+
require "event_rail/internal/execution"
|
|
21
|
+
require "event_rail/current"
|
|
22
|
+
require "event_rail/internal/context"
|
|
23
|
+
require "event_rail/job_context"
|
|
24
|
+
require "event_rail/internal/notifications"
|
|
25
|
+
require "event_rail/internal/subscriber_execution"
|
|
26
|
+
require "event_rail/subscriptions"
|
|
27
|
+
require "event_rail/internal/registry"
|
|
28
|
+
require "event_rail/internal/stamping"
|
|
29
|
+
require "event_rail/contract"
|
|
30
|
+
require "event_rail/envelope"
|
|
31
|
+
require "event_rail/internal/event_serializer"
|
|
32
|
+
require "event_rail/internal/transaction"
|
|
33
|
+
require "event_rail/publication"
|
|
34
|
+
require "event_rail/publish"
|
|
35
|
+
require "event_rail/railtie"
|
|
36
|
+
|
|
37
|
+
# The subscription macro is class-level only: it changes nothing about serialization
|
|
38
|
+
# or execution for a job that never calls it. Registering the hook here rather than in
|
|
39
|
+
# an initializer means it is in place before Active Job loads, in a Rails application
|
|
40
|
+
# and in a plain Ruby process alike.
|
|
41
|
+
ActiveSupport.on_load(:active_job) do
|
|
42
|
+
extend EventRail::Subscriptions
|
|
43
|
+
|
|
44
|
+
# Registering here rather than in an initializer keeps the serializer available to a
|
|
45
|
+
# plain Ruby process too, and it is registered exactly once because the load hook runs
|
|
46
|
+
# once.
|
|
47
|
+
ActiveJob::Serializers.add_serializers(EventRail.const_get(:Internal)::EventSerializer)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
module EventRail
|
|
51
|
+
private_constant :Internal
|
|
52
|
+
end
|