event_rail 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +47 -0
  3. data/CONTRIBUTING.md +14 -0
  4. data/MIT-LICENSE +20 -0
  5. data/README.md +364 -0
  6. data/SECURITY.md +3 -0
  7. data/lib/event_rail/contract.rb +44 -0
  8. data/lib/event_rail/current.rb +95 -0
  9. data/lib/event_rail/data.rb +4 -0
  10. data/lib/event_rail/envelope.rb +123 -0
  11. data/lib/event_rail/errors.rb +196 -0
  12. data/lib/event_rail/event.rb +258 -0
  13. data/lib/event_rail/internal/attribute_record.rb +272 -0
  14. data/lib/event_rail/internal/context.rb +32 -0
  15. data/lib/event_rail/internal/contract_index.rb +28 -0
  16. data/lib/event_rail/internal/event_serializer.rb +148 -0
  17. data/lib/event_rail/internal/execution.rb +86 -0
  18. data/lib/event_rail/internal/extensions.rb +69 -0
  19. data/lib/event_rail/internal/identity.rb +90 -0
  20. data/lib/event_rail/internal/notifications.rb +42 -0
  21. data/lib/event_rail/internal/portable_value.rb +108 -0
  22. data/lib/event_rail/internal/registry.rb +224 -0
  23. data/lib/event_rail/internal/stamping.rb +185 -0
  24. data/lib/event_rail/internal/subscriber_execution.rb +61 -0
  25. data/lib/event_rail/internal/timestamp.rb +85 -0
  26. data/lib/event_rail/internal/transaction.rb +33 -0
  27. data/lib/event_rail/internal/types.rb +441 -0
  28. data/lib/event_rail/job_context.rb +124 -0
  29. data/lib/event_rail/limits.rb +31 -0
  30. data/lib/event_rail/metadata.rb +94 -0
  31. data/lib/event_rail/portable_type.rb +35 -0
  32. data/lib/event_rail/publication.rb +33 -0
  33. data/lib/event_rail/publish.rb +88 -0
  34. data/lib/event_rail/railtie.rb +15 -0
  35. data/lib/event_rail/subscriptions.rb +59 -0
  36. data/lib/event_rail/version.rb +3 -0
  37. data/lib/event_rail.rb +52 -0
  38. metadata +184 -0
@@ -0,0 +1,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
@@ -0,0 +1,3 @@
1
+ module EventRail
2
+ VERSION = "0.1.0"
3
+ 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